chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# React Query Scope
|
||||
|
||||
These rules apply to `apps/sim/hooks/queries/**`.
|
||||
|
||||
Patterns for this directory (key factories, `signal` forwarding, `staleTime`, optimistic updates, invalidation) live in `.claude/rules/sim-queries.md`.
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isValidUuid } from '@sim/utils/id'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
|
||||
const logger = createLogger('AdminUsersQuery')
|
||||
|
||||
export const ADMIN_USER_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export const adminUserKeys = {
|
||||
all: ['adminUsers'] as const,
|
||||
lists: () => [...adminUserKeys.all, 'list'] as const,
|
||||
list: (offset: number, limit: number, searchQuery: string) =>
|
||||
[...adminUserKeys.lists(), offset, limit, searchQuery] as const,
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
banned: boolean
|
||||
banReason: string | null
|
||||
}
|
||||
|
||||
interface AdminUserListData {
|
||||
users: AdminUser[]
|
||||
total: number
|
||||
}
|
||||
|
||||
function mapUser(u: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role?: string | null
|
||||
banned?: boolean | null
|
||||
banReason?: string | null
|
||||
}): AdminUser {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name || '',
|
||||
email: u.email,
|
||||
role: u.role ?? 'user',
|
||||
banned: u.banned ?? false,
|
||||
banReason: u.banReason ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAdminUsers(
|
||||
offset: number,
|
||||
limit: number,
|
||||
searchQuery: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AdminUserListData> {
|
||||
if (isValidUuid(searchQuery.trim())) {
|
||||
const { data, error } = await client.admin.getUser(
|
||||
{ query: { id: searchQuery.trim() } },
|
||||
{ signal }
|
||||
)
|
||||
if (error) throw new Error(error.message ?? 'Failed to fetch user')
|
||||
if (!data) return { users: [], total: 0 }
|
||||
return { users: [mapUser(data)], total: 1 }
|
||||
}
|
||||
|
||||
const { data, error } = await client.admin.listUsers(
|
||||
{
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
searchField: 'email',
|
||||
searchValue: searchQuery,
|
||||
searchOperator: 'contains',
|
||||
},
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (error) throw new Error(error.message ?? 'Failed to fetch users')
|
||||
return {
|
||||
users: (data?.users ?? []).map(mapUser),
|
||||
total: data?.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminUsers(offset: number, limit: number, searchQuery: string) {
|
||||
return useQuery({
|
||||
queryKey: adminUserKeys.list(offset, limit, searchQuery),
|
||||
queryFn: ({ signal }) => fetchAdminUsers(offset, limit, searchQuery, signal),
|
||||
enabled: searchQuery.length > 0,
|
||||
staleTime: ADMIN_USER_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetUserRole() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, role }: { userId: string; role: 'user' | 'admin' }) => {
|
||||
const result = await client.admin.setRole({ userId, role })
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
|
||||
onError: (err) => {
|
||||
logger.error('Failed to set user role', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useBanUser() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, banReason }: { userId: string; banReason?: string }) => {
|
||||
const result = await client.admin.banUser({
|
||||
userId,
|
||||
...(banReason ? { banReason } : {}),
|
||||
})
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
|
||||
onError: (err) => {
|
||||
logger.error('Failed to ban user', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnbanUser() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId }: { userId: string }) => {
|
||||
const result = await client.admin.unbanUser({ userId })
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
|
||||
onError: (err) => {
|
||||
logger.error('Failed to unban user', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useImpersonateUser() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId }: { userId: string }) => {
|
||||
const result = await client.admin.impersonateUser({ userId })
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to impersonate user', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopImpersonating() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await client.admin.stopImpersonating()
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to stop impersonating', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts'
|
||||
import { getAllowedProvidersContract } from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* Query key factory for allowed providers queries
|
||||
*/
|
||||
export const allowedProvidersKeys = {
|
||||
all: ['allowedProviders'] as const,
|
||||
blacklisted: () => [...allowedProvidersKeys.all, 'blacklisted'] as const,
|
||||
}
|
||||
|
||||
export const BLACKLISTED_PROVIDERS_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
type BlacklistedProvidersResponse = ContractJsonResponse<typeof getAllowedProvidersContract>
|
||||
|
||||
async function fetchBlacklistedProviders(
|
||||
signal: AbortSignal
|
||||
): Promise<BlacklistedProvidersResponse> {
|
||||
try {
|
||||
return await requestJson(getAllowedProvidersContract, { signal })
|
||||
} catch {
|
||||
return { blacklistedProviders: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch the list of blacklisted provider IDs from the server.
|
||||
*/
|
||||
export function useBlacklistedProviders({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: allowedProvidersKeys.blacklisted(),
|
||||
queryFn: ({ signal }) => fetchBlacklistedProviders(signal),
|
||||
staleTime: BLACKLISTED_PROVIDERS_STALE_TIME,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
type ApiKey,
|
||||
createPersonalApiKeyContract,
|
||||
createWorkspaceApiKeyContract,
|
||||
deletePersonalApiKeyContract,
|
||||
deleteWorkspaceApiKeyContract,
|
||||
listPersonalApiKeysContract,
|
||||
listWorkspaceApiKeysContract,
|
||||
updateWorkspaceContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
export type { ApiKey }
|
||||
|
||||
/**
|
||||
* Query key factories for API keys-related queries
|
||||
*/
|
||||
export const apiKeysKeys = {
|
||||
all: ['apiKeys'] as const,
|
||||
workspaces: () => [...apiKeysKeys.all, 'workspace'] as const,
|
||||
workspace: (workspaceId: string) => [...apiKeysKeys.workspaces(), workspaceId] as const,
|
||||
personal: () => [...apiKeysKeys.all, 'personal'] as const,
|
||||
combineds: () => [...apiKeysKeys.all, 'combined'] as const,
|
||||
combined: (workspaceId: string) => [...apiKeysKeys.combineds(), workspaceId] as const,
|
||||
}
|
||||
|
||||
export const API_KEYS_COMBINED_STALE_TIME = 60 * 1000
|
||||
|
||||
type CombinedApiKeysData = {
|
||||
workspaceKeys: ApiKey[]
|
||||
personalKeys: ApiKey[]
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch both workspace and personal API keys
|
||||
*/
|
||||
async function fetchApiKeys(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CombinedApiKeysData> {
|
||||
const [workspaceData, personalData] = await Promise.all([
|
||||
requestJson(listWorkspaceApiKeysContract, { params: { id: workspaceId }, signal }),
|
||||
requestJson(listPersonalApiKeysContract, { signal }),
|
||||
])
|
||||
const workspaceKeys: ApiKey[] = workspaceData.keys
|
||||
const personalKeys: ApiKey[] = personalData.keys
|
||||
|
||||
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
|
||||
const conflicts = personalKeys
|
||||
.filter((key) => workspaceKeyNames.has(key.name))
|
||||
.map((key) => key.name)
|
||||
|
||||
return {
|
||||
workspaceKeys,
|
||||
personalKeys,
|
||||
conflicts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch API keys (both workspace and personal)
|
||||
*/
|
||||
export function useApiKeys(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: apiKeysKeys.combined(workspaceId),
|
||||
queryFn: ({ signal }) => fetchApiKeys(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: API_KEYS_COMBINED_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API key mutation params
|
||||
*/
|
||||
type CreateApiKeyParams = {
|
||||
workspaceId: string
|
||||
keyType: 'personal' | 'workspace'
|
||||
} & ContractBodyInput<typeof createWorkspaceApiKeyContract>
|
||||
|
||||
/**
|
||||
* Hook to create a new API key
|
||||
*/
|
||||
export function useCreateApiKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, name, keyType, source }: CreateApiKeyParams) => {
|
||||
if (keyType === 'workspace') {
|
||||
return requestJson(createWorkspaceApiKeyContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { name, source },
|
||||
})
|
||||
}
|
||||
|
||||
return requestJson(createPersonalApiKeyContract, { body: { name } })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeysKeys.combined(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key mutation params
|
||||
*/
|
||||
type DeleteApiKeyParams = {
|
||||
workspaceId: string
|
||||
keyId: string
|
||||
keyType: 'personal' | 'workspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete an API key
|
||||
*/
|
||||
export function useDeleteApiKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, keyId, keyType }: DeleteApiKeyParams) => {
|
||||
if (keyType === 'workspace') {
|
||||
return requestJson(deleteWorkspaceApiKeyContract, {
|
||||
params: { id: workspaceId, keyId },
|
||||
})
|
||||
}
|
||||
|
||||
return requestJson(deletePersonalApiKeyContract, { params: { id: keyId } })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeysKeys.combined(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace API key settings mutation params
|
||||
*/
|
||||
type UpdateWorkspaceApiKeySettingsParams = { workspaceId: string } & Pick<
|
||||
ContractBodyInput<typeof updateWorkspaceContract>,
|
||||
'allowPersonalApiKeys'
|
||||
>
|
||||
|
||||
/**
|
||||
* Hook to update workspace API key settings
|
||||
*/
|
||||
export function useUpdateWorkspaceApiKeySettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
allowPersonalApiKeys,
|
||||
}: UpdateWorkspaceApiKeySettingsParams) => {
|
||||
return requestJson(updateWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { allowPersonalApiKeys },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.settings(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type BlockVisibilityResponse,
|
||||
getBlockVisibilityContract,
|
||||
} from '@/lib/api/contracts/block-visibility'
|
||||
|
||||
export const BLOCK_VISIBILITY_STALE_TIME = 60 * 1000
|
||||
|
||||
export const blockVisibilityKeys = {
|
||||
all: ['block-visibility'] as const,
|
||||
lists: () => [...blockVisibilityKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...blockVisibilityKeys.lists(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchBlockVisibility(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<BlockVisibilityResponse> {
|
||||
return requestJson(getBlockVisibilityContract, { query: { workspaceId }, signal })
|
||||
}
|
||||
|
||||
/** The viewer's block-visibility projection for a workspace (revealed/disabled/preview-tagged types). */
|
||||
export function useBlockVisibility(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: blockVisibilityKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchBlockVisibility(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: BLOCK_VISIBILITY_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
type BYOKKey,
|
||||
type BYOKKeysResponse,
|
||||
deleteByokKeyContract,
|
||||
listByokKeysContract,
|
||||
upsertByokKeyContract,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('BYOKKeysQueries')
|
||||
|
||||
export type { BYOKKey, BYOKKeysResponse }
|
||||
|
||||
export const byokKeysKeys = {
|
||||
all: ['byok-keys'] as const,
|
||||
lists: () => [...byokKeysKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise<BYOKKeysResponse> {
|
||||
const data = await requestJson(listByokKeysContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
return {
|
||||
keys: data.keys ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function useBYOKKeys(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: byokKeysKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: BYOK_KEY_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
type UpsertBYOKKeyParams = {
|
||||
workspaceId: string
|
||||
} & ContractBodyInput<typeof upsertByokKeyContract>
|
||||
|
||||
export function useUpsertBYOKKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...body }: UpsertBYOKKeyParams) => {
|
||||
const data = await requestJson(upsertByokKeyContract, {
|
||||
params: { id: workspaceId },
|
||||
body,
|
||||
})
|
||||
logger.info(`Saved BYOK key for ${body.providerId} in workspace ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: byokKeysKeys.list(variables.workspaceId),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
type DeleteBYOKKeyParams = {
|
||||
workspaceId: string
|
||||
} & ContractBodyInput<typeof deleteByokKeyContract>
|
||||
|
||||
export function useDeleteBYOKKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...body }: DeleteBYOKKeyParams) => {
|
||||
const data = await requestJson(deleteByokKeyContract, {
|
||||
params: { id: workspaceId },
|
||||
body,
|
||||
})
|
||||
logger.info(`Deleted BYOK key for ${body.providerId} from workspace ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: byokKeysKeys.list(variables.workspaceId),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
authenticateDeployedChatContract,
|
||||
type ChatAuthType,
|
||||
type CreateChatBody,
|
||||
type CreateChatResponse,
|
||||
createChatContract,
|
||||
type DeployedChatAuthBody,
|
||||
type DeployedChatConfig,
|
||||
deleteChatContract,
|
||||
getDeployedChatConfigContract,
|
||||
requestChatEmailOtpContract,
|
||||
type UpdateChatBody,
|
||||
type UpdateChatResponse,
|
||||
updateChatContract,
|
||||
verifyChatEmailOtpContract,
|
||||
} from '@/lib/api/contracts/chats'
|
||||
import type { OutputConfig } from '@/stores/chat/types'
|
||||
import { deploymentKeys } from './deployments'
|
||||
|
||||
const logger = createLogger('ChatMutations')
|
||||
|
||||
/**
|
||||
* Query keys for chat-related queries
|
||||
*/
|
||||
export const chatKeys = {
|
||||
all: ['chats'] as const,
|
||||
status: deploymentKeys.chatStatus,
|
||||
detail: deploymentKeys.chatDetail,
|
||||
configs: () => [...chatKeys.all, 'config'] as const,
|
||||
config: (identifier?: string) => [...chatKeys.configs(), identifier ?? ''] as const,
|
||||
}
|
||||
|
||||
export const DEPLOYED_CHAT_CONFIG_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Auth types for chat access control
|
||||
*/
|
||||
export type AuthType = ChatAuthType
|
||||
|
||||
/** Deployed chat configuration returned from the public chat endpoint. */
|
||||
export type { DeployedChatConfig }
|
||||
|
||||
/**
|
||||
* Result of loading a deployed chat's configuration.
|
||||
* When the endpoint responds 401 with an auth_required_* error, the query
|
||||
* succeeds with `kind: 'auth'` so consumers can render the auth form without
|
||||
* treating it as a fetch error.
|
||||
*/
|
||||
export type DeployedChatConfigResult =
|
||||
| { kind: 'config'; config: DeployedChatConfig }
|
||||
| { kind: 'auth'; authType: 'password' | 'email' | 'sso' }
|
||||
|
||||
const AUTH_ERROR_MAP: Record<string, 'password' | 'email' | 'sso'> = {
|
||||
auth_required_password: 'password',
|
||||
auth_required_email: 'email',
|
||||
auth_required_sso: 'sso',
|
||||
}
|
||||
|
||||
async function fetchDeployedChatConfig(
|
||||
identifier: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeployedChatConfigResult> {
|
||||
try {
|
||||
const config = await requestJson(getDeployedChatConfigContract, {
|
||||
params: { identifier },
|
||||
signal,
|
||||
})
|
||||
return { kind: 'config', config }
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 401) {
|
||||
const authType = AUTH_ERROR_MAP[error.message]
|
||||
if (authType) {
|
||||
return { kind: 'auth', authType }
|
||||
}
|
||||
throw new Error('Unauthorized', { cause: error })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the public chat configuration for a deployed chat identifier.
|
||||
* Resolves to `{ kind: 'auth', authType }` when the chat requires
|
||||
* password/email/SSO gating so the consumer can render the appropriate form.
|
||||
*/
|
||||
export function useDeployedChatConfig(identifier: string) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.config(identifier),
|
||||
queryFn: ({ signal }) => fetchDeployedChatConfig(identifier, signal),
|
||||
enabled: Boolean(identifier),
|
||||
staleTime: DEPLOYED_CHAT_CONFIG_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function postChatAuth(
|
||||
identifier: string,
|
||||
body: DeployedChatAuthBody
|
||||
): Promise<DeployedChatConfig> {
|
||||
return requestJson(authenticateDeployedChatContract, {
|
||||
params: { identifier },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates against a password-gated deployed chat. On success, seeds the
|
||||
* config query cache with the returned chat config so the consumer can render
|
||||
* the chat immediately without a follow-up GET.
|
||||
*/
|
||||
export function useChatPasswordAuth(identifier: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ password }: { password: string }) => postChatAuth(identifier, { password }),
|
||||
onSuccess: (config) => {
|
||||
queryClient.setQueryData<DeployedChatConfigResult>(chatKeys.config(identifier), {
|
||||
kind: 'config',
|
||||
config,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a one-time passcode for an email-gated deployed chat.
|
||||
* Used for both the initial send and resend flows.
|
||||
*/
|
||||
export function useChatEmailOtpRequest(identifier: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ email }: { email: string }) => {
|
||||
await requestJson(requestChatEmailOtpContract, {
|
||||
params: { identifier },
|
||||
body: { email },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a one-time passcode for an email-gated deployed chat. On success,
|
||||
* seeds the config query cache with the returned chat config.
|
||||
*/
|
||||
export function useChatEmailOtpVerify(identifier: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ email, otp }: { email: string; otp: string }) => {
|
||||
return requestJson(verifyChatEmailOtpContract, {
|
||||
params: { identifier },
|
||||
body: { email, otp },
|
||||
})
|
||||
},
|
||||
onSuccess: (config) => {
|
||||
queryClient.setQueryData<DeployedChatConfigResult>(chatKeys.config(identifier), {
|
||||
kind: 'config',
|
||||
config,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Form data for creating/updating a chat
|
||||
*/
|
||||
export interface ChatFormData {
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
authType: AuthType
|
||||
password: string
|
||||
emails: string[]
|
||||
welcomeMessage: string
|
||||
selectedOutputBlocks: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for create chat mutation
|
||||
*/
|
||||
interface CreateChatVariables {
|
||||
workflowId: string
|
||||
formData: ChatFormData
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for update chat mutation
|
||||
*/
|
||||
interface UpdateChatVariables {
|
||||
chatId: string
|
||||
workflowId: string
|
||||
formData: ChatFormData
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for delete chat mutation
|
||||
*/
|
||||
interface DeleteChatVariables {
|
||||
chatId: string
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Data returned by chat create/update mutations
|
||||
*/
|
||||
type ChatMutationData =
|
||||
| Pick<CreateChatResponse, 'chatUrl' | 'chatId'>
|
||||
| Pick<UpdateChatResponse, 'chatUrl'>
|
||||
|
||||
function throwUserFriendlyIdentifierError(error: unknown): never {
|
||||
if (error instanceof ApiClientError && error.message === 'Identifier already in use') {
|
||||
throw new Error('This identifier is already in use', { cause: error })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses output block selections into structured output configs
|
||||
*/
|
||||
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
|
||||
return selectedOutputBlocks
|
||||
.map((outputId) => {
|
||||
const firstUnderscoreIndex = outputId.indexOf('_')
|
||||
if (firstUnderscoreIndex !== -1) {
|
||||
const blockId = outputId.substring(0, firstUnderscoreIndex)
|
||||
const path = outputId.substring(firstUnderscoreIndex + 1)
|
||||
if (blockId && path) {
|
||||
return { blockId, path }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((config): config is OutputConfig => config !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chat payload from form data
|
||||
*/
|
||||
function buildChatPayload(
|
||||
workflowId: string,
|
||||
formData: ChatFormData,
|
||||
imageUrl?: string | null
|
||||
): CreateChatBody {
|
||||
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
identifier: formData.identifier.trim(),
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
customizations: {
|
||||
primaryColor: 'var(--brand-hover)',
|
||||
welcomeMessage: formData.welcomeMessage.trim(),
|
||||
...(imageUrl && { imageUrl }),
|
||||
},
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password : undefined,
|
||||
allowedEmails:
|
||||
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
|
||||
outputConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for creating a new chat deployment.
|
||||
* Invalidates chat status and detail queries on success.
|
||||
*/
|
||||
export function useCreateChat() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
formData,
|
||||
imageUrl,
|
||||
}: CreateChatVariables): Promise<ChatMutationData> => {
|
||||
const payload = buildChatPayload(workflowId, formData, imageUrl)
|
||||
|
||||
try {
|
||||
const result = await requestJson(createChatContract, { body: payload })
|
||||
logger.info('Chat deployed successfully:', result.chatUrl)
|
||||
return { chatUrl: result.chatUrl, chatId: result.chatId }
|
||||
} catch (error) {
|
||||
throwUserFriendlyIdentifierError(error)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.chatStatus(variables.workflowId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(variables.workflowId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.versions(variables.workflowId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to create chat', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for updating an existing chat deployment.
|
||||
* Invalidates chat status and detail queries on success.
|
||||
*/
|
||||
export function useUpdateChat() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
chatId,
|
||||
workflowId,
|
||||
formData,
|
||||
imageUrl,
|
||||
}: UpdateChatVariables): Promise<ChatMutationData> => {
|
||||
const payload = buildChatPayload(workflowId, formData, imageUrl)
|
||||
|
||||
try {
|
||||
const result = await requestJson(updateChatContract, {
|
||||
params: { id: chatId },
|
||||
body: payload satisfies UpdateChatBody,
|
||||
})
|
||||
logger.info('Chat updated successfully:', result.chatUrl)
|
||||
return { chatUrl: result.chatUrl, chatId }
|
||||
} catch (error) {
|
||||
throwUserFriendlyIdentifierError(error)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.chatStatus(variables.workflowId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.chatDetail(variables.chatId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update chat', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for deleting a chat deployment.
|
||||
* Invalidates chat status and removes chat detail from cache on success.
|
||||
*/
|
||||
export function useDeleteChat() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ chatId }: DeleteChatVariables): Promise<void> => {
|
||||
await requestJson(deleteChatContract, { params: { id: chatId } })
|
||||
logger.info('Chat deleted successfully')
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.chatStatus(variables.workflowId),
|
||||
})
|
||||
queryClient.removeQueries({
|
||||
queryKey: deploymentKeys.chatDetail(variables.chatId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete chat', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type SubmitContactBody,
|
||||
type SubmitContactResult,
|
||||
submitContactContract,
|
||||
} from '@/lib/api/contracts/contact'
|
||||
|
||||
const logger = createLogger('ContactMutation')
|
||||
|
||||
/**
|
||||
* Submit an inbound contact request. The route emails the help inbox (replying to
|
||||
* the visitor) and sends the visitor a confirmation. Used by the public `/contact`
|
||||
* form; the honeypot and captcha fields ride along on the same payload.
|
||||
*/
|
||||
export function useSubmitContact() {
|
||||
return useMutation({
|
||||
mutationFn: (variables: SubmitContactBody): Promise<SubmitContactResult> =>
|
||||
requestJson(submitContactContract, { body: variables }),
|
||||
onError: (error) => {
|
||||
logger.error('Failed to submit contact request:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const copilotChatSelectionKeys = {
|
||||
all: ['copilot-chat-selection'] as const,
|
||||
workflows: () => [...copilotChatSelectionKeys.all, 'workflow'] as const,
|
||||
workflow: (workflowId?: string) =>
|
||||
[...copilotChatSelectionKeys.workflows(), workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive per-workflow copilot chat selection. Values are written via the
|
||||
* returned setter; queryFn is `skipToken` so the cache only ever holds
|
||||
* what setQueryData puts there.
|
||||
*/
|
||||
export function useCopilotChatSelection(workflowId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: chatId } = useQuery<string | null>({
|
||||
queryKey: copilotChatSelectionKeys.workflow(workflowId),
|
||||
queryFn: skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
initialData: null,
|
||||
})
|
||||
|
||||
const setChatId = useCallback(
|
||||
(next: string | undefined) => {
|
||||
if (!workflowId) return
|
||||
queryClient.setQueryData<string | null>(
|
||||
copilotChatSelectionKeys.workflow(workflowId),
|
||||
next ?? null
|
||||
)
|
||||
},
|
||||
[workflowId, queryClient]
|
||||
)
|
||||
|
||||
return { chatId: chatId ?? undefined, setChatId }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { type CopilotChatListItem, listCopilotChatsContract } from '@/lib/api/contracts/copilot'
|
||||
|
||||
export type { CopilotChatListItem }
|
||||
|
||||
export const copilotChatsKeys = {
|
||||
all: ['copilot-chats'] as const,
|
||||
lists: () => [...copilotChatsKeys.all, 'list'] as const,
|
||||
list: (workflowId?: string) => [...copilotChatsKeys.lists(), workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
export const COPILOT_CHAT_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
async function fetchCopilotChats(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CopilotChatListItem[]> {
|
||||
try {
|
||||
const data = await requestJson(listCopilotChatsContract, { signal })
|
||||
return data.chats.filter((c) => c.workflowId === workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow-scoped copilot chat list. Each workflowId has its own cache entry
|
||||
* so switching workflows reads the right list synchronously instead of
|
||||
* showing the previous workflow's chats during the refetch.
|
||||
*/
|
||||
export function useCopilotChats(workflowId?: string) {
|
||||
return useQuery<CopilotChatListItem[]>({
|
||||
queryKey: copilotChatsKeys.list(workflowId),
|
||||
queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken,
|
||||
staleTime: COPILOT_CHAT_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type SubmitCopilotFeedbackBody,
|
||||
type SubmitCopilotFeedbackResult,
|
||||
submitCopilotFeedbackContract,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('CopilotFeedbackMutation')
|
||||
|
||||
export function useSubmitCopilotFeedback() {
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
variables: SubmitCopilotFeedbackBody
|
||||
): Promise<SubmitCopilotFeedbackResult> => {
|
||||
return requestJson(submitCopilotFeedbackContract, { body: variables })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to submit copilot feedback:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type CopilotApiKey,
|
||||
deleteCopilotApiKeyContract,
|
||||
type GenerateCopilotApiKeyResult,
|
||||
generateCopilotApiKeyContract,
|
||||
listCopilotApiKeysContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
|
||||
const logger = createLogger('CopilotKeysQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for Copilot API keys
|
||||
*/
|
||||
export const copilotKeysKeys = {
|
||||
all: ['copilot'] as const,
|
||||
keys: () => [...copilotKeysKeys.all, 'api-keys'] as const,
|
||||
}
|
||||
|
||||
export const COPILOT_KEY_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
/**
|
||||
* Copilot API key type (re-exported from the API contract).
|
||||
*/
|
||||
export type CopilotKey = CopilotApiKey
|
||||
|
||||
/**
|
||||
* Fetch Copilot API keys
|
||||
*/
|
||||
async function fetchCopilotKeys(signal?: AbortSignal): Promise<CopilotKey[]> {
|
||||
const data = await requestJson(listCopilotApiKeysContract, { signal })
|
||||
return data.keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch Copilot API keys
|
||||
*/
|
||||
export function useCopilotKeys() {
|
||||
return useQuery({
|
||||
queryKey: copilotKeysKeys.keys(),
|
||||
queryFn: ({ signal }) => fetchCopilotKeys(signal),
|
||||
enabled: isHosted,
|
||||
staleTime: COPILOT_KEY_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate key params
|
||||
*/
|
||||
interface GenerateKeyParams {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new Copilot API key mutation
|
||||
*/
|
||||
export function useGenerateCopilotKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name }: GenerateKeyParams): Promise<GenerateCopilotApiKeyResult> => {
|
||||
return requestJson(generateCopilotApiKeyContract, { body: { name } })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to generate Copilot API key:', error)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: copilotKeysKeys.keys(),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Copilot API key mutation with optimistic updates
|
||||
*/
|
||||
interface DeleteKeyParams {
|
||||
keyId: string
|
||||
}
|
||||
|
||||
export function useDeleteCopilotKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId }: DeleteKeyParams) => {
|
||||
return requestJson(deleteCopilotApiKeyContract, { query: { id: keyId } })
|
||||
},
|
||||
onMutate: async ({ keyId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: copilotKeysKeys.keys() })
|
||||
|
||||
const previousKeys = queryClient.getQueryData<CopilotKey[]>(copilotKeysKeys.keys())
|
||||
|
||||
queryClient.setQueryData<CopilotKey[]>(copilotKeysKeys.keys(), (old) => {
|
||||
return old?.filter((k) => k.id !== keyId) || []
|
||||
})
|
||||
|
||||
return { previousKeys }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previousKeys) {
|
||||
queryClient.setQueryData(copilotKeysKeys.keys(), context.previousKeys)
|
||||
}
|
||||
logger.error('Failed to delete Copilot API key:', error)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: copilotKeysKeys.keys() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
createCredentialDraftContract,
|
||||
createWorkspaceCredentialContract,
|
||||
deleteWorkspaceCredentialContract,
|
||||
getWorkspaceCredentialContract,
|
||||
listWorkspaceCredentialMembersContract,
|
||||
listWorkspaceCredentialsContract,
|
||||
removeWorkspaceCredentialMemberContract,
|
||||
updateWorkspaceCredentialContract,
|
||||
upsertWorkspaceCredentialMemberContract,
|
||||
type WorkspaceCredential,
|
||||
type WorkspaceCredentialMember,
|
||||
type WorkspaceCredentialRole,
|
||||
type WorkspaceCredentialType,
|
||||
} from '@/lib/api/contracts'
|
||||
import { environmentKeys } from '@/hooks/queries/environment'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials'
|
||||
|
||||
/**
|
||||
* Key prefix for OAuth credential queries.
|
||||
* Duplicated here to avoid circular imports with oauth-credentials.ts.
|
||||
*/
|
||||
const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const
|
||||
|
||||
export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000
|
||||
export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export type {
|
||||
WorkspaceCredential,
|
||||
WorkspaceCredentialMember,
|
||||
WorkspaceCredentialRole,
|
||||
WorkspaceCredentialType,
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch workspace credentials into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchWorkspaceCredentials(queryClient: QueryClient, workspaceId: string) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal),
|
||||
staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceCredentials(params: {
|
||||
workspaceId?: string
|
||||
type?: WorkspaceCredentialType
|
||||
providerId?: string
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const { workspaceId, type, providerId, enabled = true } = params
|
||||
|
||||
return useQuery<WorkspaceCredential[]>({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!workspaceId) return []
|
||||
const data = await requestJson(listWorkspaceCredentialsContract, {
|
||||
query: {
|
||||
workspaceId,
|
||||
type,
|
||||
providerId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return data.credentials ?? []
|
||||
},
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceCredential(credentialId?: string, enabled = true) {
|
||||
return useQuery<WorkspaceCredential | null>({
|
||||
queryKey: workspaceCredentialKeys.detail(credentialId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!credentialId) return null
|
||||
const data = await requestJson(getWorkspaceCredentialContract, {
|
||||
params: { id: credentialId },
|
||||
signal,
|
||||
})
|
||||
return data.credential ?? null
|
||||
},
|
||||
enabled: Boolean(credentialId) && enabled,
|
||||
staleTime: WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCredentialDraft() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: ContractBodyInput<typeof createCredentialDraftContract>) => {
|
||||
await requestJson(createCredentialDraftContract, { body: payload })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateWorkspaceCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: ContractBodyInput<typeof createWorkspaceCredentialContract>) => {
|
||||
return requestJson(createWorkspaceCredentialContract, { body: payload })
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: OAUTH_CREDENTIALS_KEY,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: {
|
||||
credentialId: string
|
||||
} & ContractBodyInput<typeof updateWorkspaceCredentialContract>
|
||||
) => {
|
||||
return requestJson(updateWorkspaceCredentialContract, {
|
||||
params: { id: payload.credentialId },
|
||||
body: {
|
||||
displayName: payload.displayName,
|
||||
description: payload.description,
|
||||
serviceAccountJson: payload.serviceAccountJson,
|
||||
signingSecret: payload.signingSecret,
|
||||
botToken: payload.botToken,
|
||||
apiToken: payload.apiToken,
|
||||
domain: payload.domain,
|
||||
},
|
||||
})
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
||||
})
|
||||
await queryClient.cancelQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
|
||||
const previousLists = queryClient.getQueriesData<WorkspaceCredential[]>({
|
||||
queryKey: workspaceCredentialKeys.lists(),
|
||||
})
|
||||
|
||||
queryClient.setQueriesData<WorkspaceCredential[]>(
|
||||
{ queryKey: workspaceCredentialKeys.lists() },
|
||||
(old) => {
|
||||
if (!old) return old
|
||||
return old.map((cred) =>
|
||||
cred.id === variables.credentialId
|
||||
? {
|
||||
...cred,
|
||||
...(variables.displayName !== undefined
|
||||
? { displayName: variables.displayName }
|
||||
: {}),
|
||||
...(variables.description !== undefined
|
||||
? { description: variables.description ?? null }
|
||||
: {}),
|
||||
}
|
||||
: cred
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return { previousLists }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousLists) {
|
||||
for (const [queryKey, data] of context.previousLists) {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: OAUTH_CREDENTIALS_KEY,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteWorkspaceCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (credentialId: string) => {
|
||||
return requestJson(deleteWorkspaceCredentialContract, { params: { id: credentialId } })
|
||||
},
|
||||
onSettled: (_data, _error, credentialId) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceCredentialMembers(credentialId?: string) {
|
||||
return useQuery<WorkspaceCredentialMember[]>({
|
||||
queryKey: workspaceCredentialKeys.members(credentialId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!credentialId) return []
|
||||
const data = await requestJson(listWorkspaceCredentialMembersContract, {
|
||||
params: { id: credentialId },
|
||||
signal,
|
||||
})
|
||||
return data.members ?? []
|
||||
},
|
||||
enabled: Boolean(credentialId),
|
||||
staleTime: WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpsertWorkspaceCredentialMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: {
|
||||
credentialId: string
|
||||
} & ContractBodyInput<typeof upsertWorkspaceCredentialMemberContract>
|
||||
) => {
|
||||
return requestJson(upsertWorkspaceCredentialMemberContract, {
|
||||
params: { id: payload.credentialId },
|
||||
body: {
|
||||
userId: payload.userId,
|
||||
role: payload.role,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveWorkspaceCredentialMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: {
|
||||
credentialId: string
|
||||
} & ContractQueryInput<typeof removeWorkspaceCredentialMemberContract>
|
||||
) => {
|
||||
return requestJson(removeWorkspaceCredentialMemberContract, {
|
||||
params: { id: payload.credentialId },
|
||||
query: { userId: payload.userId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type CustomBlock,
|
||||
type CustomBlockUsageCounts,
|
||||
deleteCustomBlockContract,
|
||||
getCustomBlockUsageCountsContract,
|
||||
listCustomBlocksContract,
|
||||
type PublishCustomBlockBody,
|
||||
publishCustomBlockContract,
|
||||
type UpdateCustomBlockBody,
|
||||
updateCustomBlockContract,
|
||||
} from '@/lib/api/contracts/custom-blocks'
|
||||
|
||||
export const CUSTOM_BLOCK_LIST_STALE_TIME = 60 * 1000
|
||||
/** Short — the usage count is a pre-delete safety check and must stay fresh. */
|
||||
export const CUSTOM_BLOCK_USAGES_STALE_TIME = 30 * 1000
|
||||
|
||||
export const customBlockKeys = {
|
||||
all: ['custom-blocks'] as const,
|
||||
lists: () => [...customBlockKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const,
|
||||
usages: (id?: string) => [...customBlockKeys.all, 'usages', id ?? ''] as const,
|
||||
}
|
||||
|
||||
interface CustomBlocksResult {
|
||||
enabled: boolean
|
||||
customBlocks: CustomBlock[]
|
||||
}
|
||||
|
||||
async function fetchCustomBlocks(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CustomBlocksResult> {
|
||||
return requestJson(listCustomBlocksContract, { query: { workspaceId }, signal })
|
||||
}
|
||||
|
||||
function useCustomBlocksQuery<T>(
|
||||
workspaceId: string | undefined,
|
||||
select: (r: CustomBlocksResult) => T
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: customBlockKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchCustomBlocks(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: CUSTOM_BLOCK_LIST_STALE_TIME,
|
||||
select,
|
||||
})
|
||||
}
|
||||
|
||||
/** Org custom blocks (with live-derived input fields) available in this workspace. */
|
||||
export function useCustomBlocks(workspaceId?: string) {
|
||||
return useCustomBlocksQuery(workspaceId, (r) => r.customBlocks)
|
||||
}
|
||||
|
||||
/** Whether this workspace may publish/use custom blocks (feature flag + enterprise plan). */
|
||||
export function useCanPublishCustomBlock(workspaceId?: string) {
|
||||
return useCustomBlocksQuery(workspaceId, (r) => r.enabled)
|
||||
}
|
||||
|
||||
function fetchCustomBlockUsageCounts(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CustomBlockUsageCounts> {
|
||||
return requestJson(getCustomBlockUsageCountsContract, { params: { id }, signal })
|
||||
}
|
||||
|
||||
/** How many workflows across the org place this block (live editor state and/or active deployment). */
|
||||
export function useCustomBlockUsageCounts(blockId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: customBlockKeys.usages(blockId),
|
||||
queryFn: ({ signal }) => fetchCustomBlockUsageCounts(blockId as string, signal),
|
||||
enabled: Boolean(blockId) && (options?.enabled ?? true),
|
||||
staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePublishCustomBlock(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: PublishCustomBlockBody) => requestJson(publishCustomBlockContract, { body }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: customBlockKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCustomBlock(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: UpdateCustomBlockBody & { id: string }) =>
|
||||
requestJson(updateCustomBlockContract, { params: { id }, body }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: customBlockKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCustomBlock(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => requestJson(deleteCustomBlockContract, { params: { id } }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: customBlockKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
deleteCustomToolContract,
|
||||
listCustomToolsContract,
|
||||
upsertCustomToolsContract,
|
||||
} from '@/lib/api/contracts/tools/custom'
|
||||
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
|
||||
|
||||
const logger = createLogger('CustomToolsQueries')
|
||||
|
||||
export const CUSTOM_TOOL_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
interface CustomToolSchema {
|
||||
[key: string]: unknown
|
||||
type: 'function'
|
||||
function: {
|
||||
[key: string]: unknown
|
||||
name: string
|
||||
description?: string
|
||||
parameters: {
|
||||
[key: string]: unknown
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomToolDefinition {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
userId: string | null
|
||||
title: string
|
||||
schema: CustomToolSchema
|
||||
code: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CustomTool = CustomToolDefinition
|
||||
|
||||
type ApiCustomTool = Partial<CustomToolDefinition> & {
|
||||
id: string
|
||||
title: string
|
||||
schema: Partial<CustomToolSchema> & {
|
||||
function?: Partial<CustomToolSchema['function']> & {
|
||||
parameters?: Partial<CustomToolSchema['function']['parameters']>
|
||||
}
|
||||
}
|
||||
code?: string
|
||||
}
|
||||
|
||||
function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition {
|
||||
const fallbackName = tool.schema.function?.name || tool.id
|
||||
const parameters = tool.schema.function?.parameters ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
title: tool.title,
|
||||
code: typeof tool.code === 'string' ? tool.code : '',
|
||||
workspaceId: tool.workspaceId ?? workspaceId ?? null,
|
||||
userId: tool.userId ?? null,
|
||||
createdAt:
|
||||
typeof tool.createdAt === 'string'
|
||||
? tool.createdAt
|
||||
: tool.updatedAt && typeof tool.updatedAt === 'string'
|
||||
? tool.updatedAt
|
||||
: new Date().toISOString(),
|
||||
updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined,
|
||||
schema: {
|
||||
type: tool.schema.type ?? 'function',
|
||||
function: {
|
||||
name: fallbackName,
|
||||
description: tool.schema.function?.description,
|
||||
parameters: {
|
||||
type: parameters.type ?? 'object',
|
||||
properties: parameters.properties ?? {},
|
||||
required: parameters.required,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch custom tools for a workspace
|
||||
*/
|
||||
async function fetchCustomTools(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CustomToolDefinition[]> {
|
||||
const { data } = await requestJson(listCustomToolsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
|
||||
const normalizedTools: CustomToolDefinition[] = []
|
||||
|
||||
data.forEach((tool, index) => {
|
||||
if (!isRecordLike(tool)) {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: not an object`)
|
||||
return
|
||||
}
|
||||
if (!tool.id || typeof tool.id !== 'string') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`)
|
||||
return
|
||||
}
|
||||
if (!tool.title || typeof tool.title !== 'string') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`)
|
||||
return
|
||||
}
|
||||
if (!isRecordLike(tool.schema)) {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`)
|
||||
return
|
||||
}
|
||||
if (!isRecordLike(tool.schema.function)) {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing function schema`)
|
||||
return
|
||||
}
|
||||
|
||||
const functionSchema = tool.schema.function
|
||||
const parameters = isRecordLike(functionSchema.parameters) ? functionSchema.parameters : {}
|
||||
const properties = isRecordLike(parameters.properties) ? parameters.properties : {}
|
||||
const required = Array.isArray(parameters.required)
|
||||
? parameters.required.filter((value): value is string => typeof value === 'string')
|
||||
: undefined
|
||||
|
||||
const apiTool: ApiCustomTool = {
|
||||
id: tool.id,
|
||||
title: tool.title,
|
||||
schema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: typeof functionSchema.name === 'string' ? functionSchema.name : tool.id,
|
||||
description:
|
||||
typeof functionSchema.description === 'string' ? functionSchema.description : undefined,
|
||||
parameters: {
|
||||
type: typeof parameters.type === 'string' ? parameters.type : 'object',
|
||||
properties,
|
||||
required,
|
||||
},
|
||||
},
|
||||
},
|
||||
code: typeof tool.code === 'string' ? tool.code : '',
|
||||
workspaceId: typeof tool.workspaceId === 'string' ? tool.workspaceId : null,
|
||||
userId: typeof tool.userId === 'string' ? tool.userId : null,
|
||||
createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined,
|
||||
updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
normalizedTools.push(normalizeCustomTool(apiTool, workspaceId))
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to normalize custom tool at index ${index}`, { error })
|
||||
}
|
||||
})
|
||||
|
||||
return normalizedTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch custom tools
|
||||
*/
|
||||
export function useCustomTools(workspaceId: string) {
|
||||
return useQuery<CustomToolDefinition[]>({
|
||||
queryKey: customToolsKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: CUSTOM_TOOL_LIST_STALE_TIME, // 1 minute - tools don't change frequently
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom tool mutation
|
||||
*/
|
||||
interface CreateCustomToolParams {
|
||||
workspaceId: string
|
||||
tool: {
|
||||
title: string
|
||||
schema: CustomToolSchema
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, tool }: CreateCustomToolParams) => {
|
||||
logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`)
|
||||
|
||||
const data = await requestJson(upsertCustomToolsContract, {
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
title: tool.title,
|
||||
schema: tool.schema,
|
||||
code: tool.code,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid API response: missing tools data')
|
||||
}
|
||||
|
||||
logger.info(`Created custom tool: ${tool.title}`)
|
||||
return data.data as CustomToolDefinition[]
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom tool mutation
|
||||
*/
|
||||
interface UpdateCustomToolParams {
|
||||
workspaceId: string
|
||||
toolId: string
|
||||
updates: {
|
||||
title?: string
|
||||
schema?: CustomToolSchema
|
||||
code?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, toolId, updates }: UpdateCustomToolParams) => {
|
||||
logger.info(`Updating custom tool: ${toolId} in workspace ${workspaceId}`)
|
||||
|
||||
const currentTools = queryClient.getQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
)
|
||||
const currentTool = currentTools?.find((t) => t.id === toolId)
|
||||
|
||||
if (!currentTool) {
|
||||
throw new Error('Tool not found')
|
||||
}
|
||||
|
||||
const data = await requestJson(upsertCustomToolsContract, {
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
id: toolId,
|
||||
title: updates.title ?? currentTool.title,
|
||||
schema: updates.schema ?? currentTool.schema,
|
||||
code: updates.code ?? currentTool.code,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid API response: missing tools data')
|
||||
}
|
||||
|
||||
logger.info(`Updated custom tool: ${toolId}`)
|
||||
return data.data as CustomToolDefinition[]
|
||||
},
|
||||
onMutate: async ({ workspaceId, toolId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
|
||||
|
||||
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
if (previousTools) {
|
||||
queryClient.setQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId),
|
||||
previousTools.map((tool) =>
|
||||
tool.id === toolId
|
||||
? {
|
||||
...tool,
|
||||
title: updates.title ?? tool.title,
|
||||
schema: updates.schema ?? tool.schema,
|
||||
code: updates.code ?? tool.code,
|
||||
}
|
||||
: tool
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousTools }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousTools) {
|
||||
queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete custom tool mutation
|
||||
*/
|
||||
interface DeleteCustomToolParams {
|
||||
workspaceId: string | null
|
||||
toolId: string
|
||||
}
|
||||
|
||||
export function useDeleteCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, toolId }: DeleteCustomToolParams) => {
|
||||
logger.info(`Deleting custom tool: ${toolId}`)
|
||||
|
||||
const data = await requestJson(deleteCustomToolContract, {
|
||||
query: { id: toolId, workspaceId: workspaceId ?? undefined },
|
||||
})
|
||||
|
||||
logger.info(`Deleted custom tool: ${toolId}`)
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ workspaceId, toolId }) => {
|
||||
if (!workspaceId) return
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
|
||||
|
||||
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
if (previousTools) {
|
||||
queryClient.setQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId),
|
||||
previousTools.filter((tool) => tool.id !== toolId)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousTools, workspaceId }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousTools && context?.workspaceId) {
|
||||
queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
if (variables.workspaceId) {
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type DemoRequestBody,
|
||||
type DemoRequestResult,
|
||||
submitDemoRequestContract,
|
||||
} from '@/lib/api/contracts/demo-requests'
|
||||
|
||||
const logger = createLogger('DemoRequestMutation')
|
||||
|
||||
/**
|
||||
* Submit an inbound demo request. The route notifies the sales inbox
|
||||
* (`enterprise@`, replying to the visitor) — no email is sent to the visitor.
|
||||
* Used as a best-effort notification from the demo-booking flow: failures are
|
||||
* logged and never block the visitor from scheduling.
|
||||
*/
|
||||
export function useSubmitDemoRequest() {
|
||||
return useMutation({
|
||||
mutationFn: async (variables: DemoRequestBody): Promise<DemoRequestResult> => {
|
||||
return requestJson(submitDemoRequestContract, { body: variables })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to submit demo request:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateDeploymentQueries, refetchDeploymentBoundary } from '@/hooks/queries/deployments'
|
||||
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
|
||||
|
||||
describe('deployment query helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('invalidates the deployment info, state, versions, and public surface queries', async () => {
|
||||
const queryClient = {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
await invalidateDeploymentQueries(queryClient as any, 'wf-1')
|
||||
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(4)
|
||||
expect(queryClient.invalidateQueries.mock.calls.map(([call]) => call)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ queryKey: ['deployments', 'info', 'wf-1'] },
|
||||
{ queryKey: ['deployments', 'deployedState', 'wf-1'] },
|
||||
{ queryKey: ['deployments', 'versions', 'wf-1'] },
|
||||
{ queryKey: ['deployments', 'chatStatus', 'wf-1'] },
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('refetches the deploy comparison boundary after invalidating it', async () => {
|
||||
const queryClient = {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
refetchQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
await refetchDeploymentBoundary(queryClient as any, 'wf-1')
|
||||
|
||||
expect(queryClient.refetchQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['deployments', 'info', 'wf-1'],
|
||||
})
|
||||
expect(queryClient.refetchQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['deployments', 'deployedState', 'wf-1'],
|
||||
})
|
||||
expect(queryClient.refetchQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['workflows', 'state', 'wf-1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches deployment version state through the shared helper', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
deployedState: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
) as typeof fetch
|
||||
|
||||
await expect(fetchDeploymentVersionState('wf-1', 3)).resolves.toEqual({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: 1,
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/workflows/wf-1/deployments/3', {
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
body: undefined,
|
||||
signal: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,619 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson, requestRaw } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ActivateDeploymentVersionResponse,
|
||||
activateDeploymentVersionContract,
|
||||
type ChatDeploymentStatus,
|
||||
type ChatDetail,
|
||||
type DeploymentInfoResponse,
|
||||
type DeploymentVersionsResponse,
|
||||
type DeployWorkflowResponse,
|
||||
deployWorkflowContract,
|
||||
getChatDeploymentStatusContract,
|
||||
getChatDetailContract,
|
||||
getDeployedWorkflowStateContract,
|
||||
getDeploymentInfoContract,
|
||||
listDeploymentVersionsContract,
|
||||
type UpdateDeploymentVersionMetadataResponse,
|
||||
undeployWorkflowContract,
|
||||
updateDeploymentVersionMetadataContract,
|
||||
updatePublicApiContract,
|
||||
} from '@/lib/api/contracts/deployments'
|
||||
import { wandGenerateStreamContract } from '@/lib/api/contracts/hotspots'
|
||||
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('DeploymentQueries')
|
||||
|
||||
export type { ChatDetail, DeploymentVersionsResponse }
|
||||
|
||||
export const DEPLOYMENT_INFO_STALE_TIME = 30 * 1000
|
||||
export const DEPLOYED_WORKFLOW_STATE_STALE_TIME = 30 * 1000
|
||||
export const DEPLOYMENT_VERSIONS_STALE_TIME = 30 * 1000
|
||||
export const CHAT_DEPLOYMENT_STATUS_STALE_TIME = 30 * 1000
|
||||
export const CHAT_DETAIL_STALE_TIME = 30 * 1000
|
||||
|
||||
/**
|
||||
* Query key factory for deployment-related queries
|
||||
*/
|
||||
export const deploymentKeys = {
|
||||
all: ['deployments'] as const,
|
||||
infos: () => [...deploymentKeys.all, 'info'] as const,
|
||||
info: (workflowId: string | null) => [...deploymentKeys.infos(), workflowId ?? ''] as const,
|
||||
deployedState: (workflowId: string | null) =>
|
||||
[...deploymentKeys.all, 'deployedState', workflowId ?? ''] as const,
|
||||
allVersions: () => [...deploymentKeys.all, 'versions'] as const,
|
||||
versions: (workflowId: string | null) =>
|
||||
[...deploymentKeys.allVersions(), workflowId ?? ''] as const,
|
||||
chatStatuses: () => [...deploymentKeys.all, 'chatStatus'] as const,
|
||||
chatStatus: (workflowId: string | null) =>
|
||||
[...deploymentKeys.chatStatuses(), workflowId ?? ''] as const,
|
||||
chatDetails: () => [...deploymentKeys.all, 'chatDetail'] as const,
|
||||
chatDetail: (chatId: string | null) => [...deploymentKeys.chatDetails(), chatId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the core deployment queries (info, deployedState, versions) for a workflow.
|
||||
* Used by mutation onSuccess callbacks and manual invalidation after chat deployments.
|
||||
*/
|
||||
export function invalidateDeploymentQueries(queryClient: QueryClient, workflowId: string) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }),
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.deployedState(workflowId) }),
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }),
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) }),
|
||||
])
|
||||
}
|
||||
|
||||
export async function refetchDeploymentBoundary(queryClient: QueryClient, workflowId: string) {
|
||||
await invalidateDeploymentQueries(queryClient, workflowId)
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: deploymentKeys.info(workflowId) }),
|
||||
queryClient.refetchQueries({ queryKey: deploymentKeys.deployedState(workflowId) }),
|
||||
queryClient.refetchQueries({ queryKey: workflowKeys.state(workflowId) }),
|
||||
])
|
||||
}
|
||||
|
||||
export type WorkflowDeploymentInfo = DeploymentInfoResponse & {
|
||||
deployedAt: string | null
|
||||
apiKey: string | null
|
||||
needsRedeployment: boolean
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches deployment info for a workflow
|
||||
*/
|
||||
async function fetchDeploymentInfo(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowDeploymentInfo> {
|
||||
const data = await requestJson(getDeploymentInfoContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return {
|
||||
isDeployed: data.isDeployed ?? false,
|
||||
deployedAt: data.deployedAt ?? null,
|
||||
apiKey: data.apiKey ?? null,
|
||||
needsRedeployment: data.needsRedeployment ?? false,
|
||||
isPublicApi: data.isPublicApi ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch deployment info for a workflow.
|
||||
* Provides isDeployed status, deployedAt timestamp, apiKey info, and needsRedeployment flag.
|
||||
*/
|
||||
export function useDeploymentInfo(
|
||||
workflowId: string | null,
|
||||
options?: { enabled?: boolean; refetchOnMount?: boolean | 'always' }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: deploymentKeys.info(workflowId),
|
||||
queryFn: ({ signal }) => fetchDeploymentInfo(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: DEPLOYMENT_INFO_STALE_TIME,
|
||||
...(options?.refetchOnMount !== undefined && { refetchOnMount: options.refetchOnMount }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the deployed workflow state snapshot for a workflow
|
||||
*/
|
||||
async function fetchDeployedWorkflowState(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowState | null> {
|
||||
const data = await requestJson(getDeployedWorkflowStateContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return data.deployedState || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch the deployed workflow state snapshot.
|
||||
* Returns the full workflow state at the time of the last active deployment.
|
||||
*/
|
||||
export function useDeployedWorkflowState(
|
||||
workflowId: string | null,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: deploymentKeys.deployedState(workflowId),
|
||||
queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: DEPLOYED_WORKFLOW_STATE_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all deployment versions for a workflow
|
||||
*/
|
||||
async function fetchDeploymentVersions(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeploymentVersionsResponse> {
|
||||
const data = await requestJson(listDeploymentVersionsContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return {
|
||||
versions: Array.isArray(data.versions) ? data.versions : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch deployment versions for a workflow.
|
||||
* Returns a list of all deployment versions with their metadata.
|
||||
*/
|
||||
export function useDeploymentVersions(workflowId: string | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: deploymentKeys.versions(workflowId),
|
||||
queryFn: ({ signal }) => fetchDeploymentVersions(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: DEPLOYMENT_VERSIONS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches chat deployment status for a workflow
|
||||
*/
|
||||
async function fetchChatDeploymentStatus(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ChatDeploymentStatus> {
|
||||
const data = await requestJson(getChatDeploymentStatusContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return {
|
||||
isDeployed: data.isDeployed ?? false,
|
||||
deployment: data.deployment ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch chat deployment status for a workflow.
|
||||
* Returns whether a chat is deployed and basic deployment info.
|
||||
*/
|
||||
export function useChatDeploymentStatus(
|
||||
workflowId: string | null,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: deploymentKeys.chatStatus(workflowId),
|
||||
queryFn: ({ signal }) => fetchChatDeploymentStatus(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: CHAT_DEPLOYMENT_STATUS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches chat detail by chat ID
|
||||
*/
|
||||
async function fetchChatDetail(chatId: string, signal?: AbortSignal): Promise<ChatDetail> {
|
||||
return requestJson(getChatDetailContract, {
|
||||
params: { id: chatId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch chat detail by chat ID.
|
||||
* Returns full chat configuration including customizations and auth settings.
|
||||
*/
|
||||
export function useChatDetail(chatId: string | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: deploymentKeys.chatDetail(chatId),
|
||||
queryFn: ({ signal }) => fetchChatDetail(chatId!, signal),
|
||||
enabled: Boolean(chatId) && (options?.enabled ?? true),
|
||||
staleTime: CHAT_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook to fetch chat deployment info for a workflow.
|
||||
* First fetches the chat status, then if deployed, fetches the chat detail.
|
||||
* Returns the combined result.
|
||||
*/
|
||||
export function useChatDeploymentInfo(workflowId: string | null, options?: { enabled?: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const statusQuery = useChatDeploymentStatus(workflowId, options)
|
||||
|
||||
const chatId = statusQuery.data?.deployment?.id ?? null
|
||||
|
||||
const detailQuery = useChatDetail(chatId, {
|
||||
enabled: Boolean(chatId) && statusQuery.isSuccess && (options?.enabled ?? true),
|
||||
})
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const statusResult = await statusQuery.refetch()
|
||||
const nextChatId = statusResult.data?.deployment?.id
|
||||
if (nextChatId) {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: deploymentKeys.chatDetail(nextChatId),
|
||||
queryFn: ({ signal }) => fetchChatDetail(nextChatId, signal),
|
||||
staleTime: CHAT_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
}, [queryClient, statusQuery.refetch])
|
||||
|
||||
return {
|
||||
isLoading:
|
||||
statusQuery.isLoading || Boolean(statusQuery.data?.isDeployed && detailQuery.isLoading),
|
||||
isError: statusQuery.isError || detailQuery.isError,
|
||||
error: statusQuery.error ?? detailQuery.error,
|
||||
chatExists: statusQuery.data?.isDeployed ?? false,
|
||||
existingChat: detailQuery.data ?? null,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for deploy workflow mutation
|
||||
*/
|
||||
interface DeployWorkflowVariables {
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
type DeployWorkflowResult = Omit<DeployWorkflowResponse, 'deployedAt' | 'apiKey'> & {
|
||||
deployedAt?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for deploying a workflow.
|
||||
* Invalidates deployment info and versions queries on success.
|
||||
*/
|
||||
export function useDeployWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId }: DeployWorkflowVariables): Promise<DeployWorkflowResult> => {
|
||||
const data = await requestJson(deployWorkflowContract, {
|
||||
params: { id: workflowId },
|
||||
})
|
||||
return {
|
||||
isDeployed: data.isDeployed ?? false,
|
||||
deployedAt: data.deployedAt ?? undefined,
|
||||
apiKey: data.apiKey ?? undefined,
|
||||
warnings: data.warnings,
|
||||
}
|
||||
},
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (error) {
|
||||
logger.error('Failed to deploy workflow', { error })
|
||||
return invalidateDeploymentQueries(queryClient, variables.workflowId)
|
||||
}
|
||||
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
|
||||
return refetchDeploymentBoundary(queryClient, variables.workflowId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for undeploy workflow mutation
|
||||
*/
|
||||
interface UndeployWorkflowVariables {
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for undeploying a workflow.
|
||||
* Invalidates deployment info and versions queries on success.
|
||||
*/
|
||||
export function useUndeployWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId }: UndeployWorkflowVariables) => {
|
||||
return requestJson(undeployWorkflowContract, {
|
||||
params: { id: workflowId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (error) {
|
||||
logger.error('Failed to undeploy workflow', { error })
|
||||
} else {
|
||||
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
|
||||
}
|
||||
return Promise.all([
|
||||
invalidateDeploymentQueries(queryClient, variables.workflowId),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.chatStatus(variables.workflowId),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for update deployment version mutation
|
||||
*/
|
||||
interface UpdateDeploymentVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
name?: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
type UpdateDeploymentVersionResult = UpdateDeploymentVersionMetadataResponse
|
||||
|
||||
/**
|
||||
* Mutation hook for updating a deployment version's name or description.
|
||||
* Invalidates versions query on success.
|
||||
*/
|
||||
export function useUpdateDeploymentVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
name,
|
||||
description,
|
||||
}: UpdateDeploymentVersionVariables): Promise<UpdateDeploymentVersionResult> => {
|
||||
return requestJson(updateDeploymentVersionMetadataContract, {
|
||||
params: { id: workflowId, version },
|
||||
body: { name, description },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (!error) {
|
||||
logger.info('Deployment version updated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
} else {
|
||||
logger.error('Failed to update deployment version', { error })
|
||||
}
|
||||
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.versions(variables.workflowId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for generating a version description
|
||||
*/
|
||||
interface GenerateVersionDescriptionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
onStreamChunk?: (accumulated: string) => void
|
||||
/** Aborts the diff fetches + SSE stream when the modal unmounts mid-generation. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform.
|
||||
|
||||
Write a brief, factual description (1-3 sentences, under 2000 characters) that states what changed between versions.
|
||||
|
||||
Guidelines:
|
||||
- Use the specific values provided (credential names, channel names, model names)
|
||||
- Be precise: "Changes Slack channel from #general to #alerts" not "Updates channel configuration"
|
||||
- Combine related changes: "Updates Agent model to claude-sonnet-4-5 and increases temperature to 0.8"
|
||||
- For added/removed blocks, mention their purpose if clear from the type
|
||||
|
||||
Format rules:
|
||||
- Plain text only, no quotes around the response
|
||||
- No markdown formatting
|
||||
- No filler phrases ("for improved efficiency", "streamlining the workflow")
|
||||
- No version numbers or "This version" prefixes
|
||||
|
||||
Examples:
|
||||
- Switches Agent model from gpt-4o to claude-sonnet-4-5. Changes Slack credential to Production OAuth.
|
||||
- Adds Gmail notification block for sending alerts. Removes unused Function block. Updates Router conditions.
|
||||
- Updates system prompt for more concise responses. Reduces temperature from 0.7 to 0.3.
|
||||
- Connects Slack block to Router. Adds 2 new workflow connections. Configures error handling path.`
|
||||
|
||||
/**
|
||||
* Hook for generating a version description using AI based on workflow diff
|
||||
*/
|
||||
export function useGenerateVersionDescription() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk,
|
||||
signal,
|
||||
}: GenerateVersionDescriptionVariables): Promise<string> => {
|
||||
const { generateWorkflowDiffSummary, formatDiffSummaryForDescriptionAsync } = await import(
|
||||
'@/lib/workflows/comparison/compare'
|
||||
)
|
||||
|
||||
const currentState = await fetchDeploymentVersionState(workflowId, version, signal)
|
||||
|
||||
let previousState = null
|
||||
if (version > 1) {
|
||||
try {
|
||||
previousState = await fetchDeploymentVersionState(workflowId, version - 1, signal)
|
||||
} catch {
|
||||
// Previous version may not exist, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
const diffSummary = generateWorkflowDiffSummary(currentState, previousState)
|
||||
const diffText = await formatDiffSummaryForDescriptionAsync(
|
||||
diffSummary,
|
||||
currentState,
|
||||
workflowId
|
||||
)
|
||||
|
||||
const wandResponse = await requestRaw(
|
||||
wandGenerateStreamContract,
|
||||
{
|
||||
body: {
|
||||
prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`,
|
||||
systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT,
|
||||
stream: true,
|
||||
workflowId,
|
||||
},
|
||||
signal,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!wandResponse.body) {
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const { readSSEStream } = await import('@/lib/core/utils/sse')
|
||||
const accumulatedContent = await readSSEStream(wandResponse.body, {
|
||||
onAccumulated: onStreamChunk,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!accumulatedContent) {
|
||||
throw new Error('Failed to generate description')
|
||||
}
|
||||
|
||||
return accumulatedContent.trim()
|
||||
},
|
||||
onSuccess: (content) => {
|
||||
logger.info('Generated version description', { length: content.length })
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.name === 'AbortError') return
|
||||
logger.error('Failed to generate version description', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for activate version mutation
|
||||
*/
|
||||
interface ActivateVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
}
|
||||
|
||||
type ActivateVersionResult = ActivateDeploymentVersionResponse
|
||||
|
||||
/**
|
||||
* Mutation hook for activating (promoting) a specific deployment version.
|
||||
* Invalidates deployment info and versions queries on success.
|
||||
*/
|
||||
export function useActivateDeploymentVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
}: ActivateVersionVariables): Promise<ActivateVersionResult> => {
|
||||
return requestJson(activateDeploymentVersionContract, {
|
||||
params: { id: workflowId, version },
|
||||
body: { isActive: true },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ workflowId, version }) => {
|
||||
await queryClient.cancelQueries({ queryKey: deploymentKeys.versions(workflowId) })
|
||||
|
||||
const previousVersions = queryClient.getQueryData<DeploymentVersionsResponse>(
|
||||
deploymentKeys.versions(workflowId)
|
||||
)
|
||||
|
||||
if (previousVersions) {
|
||||
queryClient.setQueryData<DeploymentVersionsResponse>(deploymentKeys.versions(workflowId), {
|
||||
versions: previousVersions.versions.map((v) => ({
|
||||
...v,
|
||||
isActive: v.version === version,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return { previousVersions }
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
logger.error('Failed to activate deployment version')
|
||||
|
||||
if (context?.previousVersions) {
|
||||
queryClient.setQueryData(
|
||||
deploymentKeys.versions(variables.workflowId),
|
||||
context.previousVersions
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (!error) {
|
||||
logger.info('Deployment version activated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
}
|
||||
return invalidateDeploymentQueries(queryClient, variables.workflowId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for updating public API access
|
||||
*/
|
||||
interface UpdatePublicApiVariables {
|
||||
workflowId: string
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for toggling a workflow's public API access.
|
||||
* Invalidates deployment info query on success.
|
||||
*/
|
||||
export function useUpdatePublicApi() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, isPublicApi }: UpdatePublicApiVariables) => {
|
||||
return requestJson(updatePublicApiContract, {
|
||||
params: { id: workflowId },
|
||||
body: { isPublicApi },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (!error) {
|
||||
logger.info('Public API setting updated', {
|
||||
workflowId: variables.workflowId,
|
||||
isPublicApi: variables.isPublicApi,
|
||||
})
|
||||
} else {
|
||||
logger.error('Failed to update public API setting', { error })
|
||||
}
|
||||
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(variables.workflowId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ContractBodyInput,
|
||||
removeWorkspaceEnvironmentContract,
|
||||
savePersonalEnvironmentContract,
|
||||
upsertWorkspaceEnvironmentContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api'
|
||||
|
||||
const logger = createLogger('EnvironmentQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for environment variable queries
|
||||
*/
|
||||
export const PERSONAL_ENVIRONMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKSPACE_ENVIRONMENT_STALE_TIME = 60 * 1000
|
||||
|
||||
export const environmentKeys = {
|
||||
all: ['environment'] as const,
|
||||
personal: () => [...environmentKeys.all, 'personal'] as const,
|
||||
workspaces: () => [...environmentKeys.all, 'workspace'] as const,
|
||||
workspace: (workspaceId: string) => [...environmentKeys.workspaces(), workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch personal environment variables
|
||||
*/
|
||||
export function usePersonalEnvironment() {
|
||||
return useQuery({
|
||||
queryKey: environmentKeys.personal(),
|
||||
queryFn: ({ signal }) => fetchPersonalEnvironment(signal),
|
||||
staleTime: PERSONAL_ENVIRONMENT_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace environment variables
|
||||
*/
|
||||
export function useWorkspaceEnvironment<TData = WorkspaceEnvironmentData>(
|
||||
workspaceId: string,
|
||||
options?: { select?: (data: WorkspaceEnvironmentData) => TData }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: environmentKeys.workspace(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save personal environment variables mutation
|
||||
*/
|
||||
type SavePersonalEnvironmentParams = ContractBodyInput<typeof savePersonalEnvironmentContract>
|
||||
|
||||
export function useSavePersonalEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ variables }: SavePersonalEnvironmentParams) => {
|
||||
await requestJson(savePersonalEnvironmentContract, { body: { variables } })
|
||||
|
||||
logger.info('Saved personal environment variables')
|
||||
},
|
||||
onSettled: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }),
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert workspace environment variables mutation
|
||||
*/
|
||||
type UpsertWorkspaceEnvironmentParams = { workspaceId: string } & ContractBodyInput<
|
||||
typeof upsertWorkspaceEnvironmentContract
|
||||
>
|
||||
|
||||
export function useUpsertWorkspaceEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, variables }: UpsertWorkspaceEnvironmentParams) => {
|
||||
const data = await requestJson(upsertWorkspaceEnvironmentContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { variables },
|
||||
})
|
||||
logger.info(`Upserted workspace environment variables for workspace: ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove workspace environment variables mutation
|
||||
*/
|
||||
type RemoveWorkspaceEnvironmentParams = { workspaceId: string } & ContractBodyInput<
|
||||
typeof removeWorkspaceEnvironmentContract
|
||||
>
|
||||
|
||||
export function useRemoveWorkspaceEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, keys }: RemoveWorkspaceEnvironmentParams) => {
|
||||
const data = await requestJson(removeWorkspaceEnvironmentContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { keys },
|
||||
})
|
||||
logger.info(`Removed ${keys.length} workspace environment keys for workspace: ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetFolderMap, mockGetWorkflows, queryClient } = vi.hoisted(() => ({
|
||||
mockGetFolderMap: vi.fn(() => ({})),
|
||||
mockGetWorkflows: vi.fn(() => []),
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryData: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
let folderMapState: Record<string, any>
|
||||
let folderListState: any[]
|
||||
|
||||
let workflowList: Array<{
|
||||
id: string
|
||||
name: string
|
||||
workspaceId: string
|
||||
folderId: string
|
||||
sortOrder: number
|
||||
}>
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: {},
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(() => queryClient),
|
||||
useMutation: vi.fn((options) => options),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
|
||||
getWorkflows: mockGetWorkflows,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
|
||||
getFolderMap: mockGetFolderMap,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/workflow-keys', () => ({
|
||||
workflowKeys: {
|
||||
list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''],
|
||||
},
|
||||
}))
|
||||
|
||||
import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders'
|
||||
|
||||
function getOptimisticFolderByName(name: string) {
|
||||
return Object.values(folderMapState).find((folder: any) => folder.name === name) as
|
||||
| { sortOrder: number }
|
||||
| undefined
|
||||
}
|
||||
|
||||
describe('folder optimistic top insertion ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient.getQueryData.mockImplementation(() => folderListState)
|
||||
queryClient.setQueryData.mockImplementation((_key: unknown, updater: any) => {
|
||||
folderListState = typeof updater === 'function' ? updater(folderListState) : updater
|
||||
folderMapState = Object.fromEntries(
|
||||
(folderListState ?? []).map((folder: any) => [folder.id, folder])
|
||||
)
|
||||
})
|
||||
mockGetFolderMap.mockImplementation(() => folderMapState)
|
||||
mockGetWorkflows.mockImplementation(() => workflowList)
|
||||
|
||||
folderListState = [
|
||||
{
|
||||
id: 'folder-parent-match',
|
||||
name: 'Existing sibling folder',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: 'parent-1',
|
||||
color: '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: 5,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'folder-other-parent',
|
||||
name: 'Other parent folder',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: 'parent-2',
|
||||
color: '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: -100,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]
|
||||
folderMapState = Object.fromEntries(folderListState.map((folder) => [folder.id, folder]))
|
||||
|
||||
workflowList = [
|
||||
{
|
||||
id: 'workflow-parent-match',
|
||||
name: 'Existing sibling workflow',
|
||||
workspaceId: 'ws-1',
|
||||
folderId: 'parent-1',
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
id: 'workflow-other-parent',
|
||||
name: 'Other parent workflow',
|
||||
workspaceId: 'ws-1',
|
||||
folderId: 'parent-2',
|
||||
sortOrder: -50,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
it('creates folders at top of mixed non-root siblings', async () => {
|
||||
const mutation = useCreateFolder()
|
||||
|
||||
await mutation.onMutate({
|
||||
workspaceId: 'ws-1',
|
||||
name: 'New child folder',
|
||||
parentId: 'parent-1',
|
||||
})
|
||||
|
||||
const optimisticFolder = getOptimisticFolderByName('New child folder')
|
||||
expect(optimisticFolder).toBeDefined()
|
||||
expect(optimisticFolder?.sortOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('duplicates folders at top of mixed non-root siblings', async () => {
|
||||
const mutation = useDuplicateFolderMutation()
|
||||
|
||||
await mutation.onMutate({
|
||||
workspaceId: 'ws-1',
|
||||
id: 'folder-parent-match',
|
||||
name: 'Duplicated child folder',
|
||||
parentId: 'parent-1',
|
||||
})
|
||||
|
||||
const optimisticFolder = getOptimisticFolderByName('Duplicated child folder')
|
||||
expect(optimisticFolder).toBeDefined()
|
||||
expect(optimisticFolder?.sortOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('uses source parent scope when duplicate parentId is undefined', async () => {
|
||||
const mutation = useDuplicateFolderMutation()
|
||||
|
||||
await mutation.onMutate({
|
||||
workspaceId: 'ws-1',
|
||||
id: 'folder-parent-match',
|
||||
name: 'Duplicated with inherited parent',
|
||||
// parentId intentionally omitted to mirror duplicate fallback behavior
|
||||
})
|
||||
|
||||
const optimisticFolder = getOptimisticFolderByName('Duplicated with inherited parent') as
|
||||
| { parentId: string | null; sortOrder: number }
|
||||
| undefined
|
||||
expect(optimisticFolder).toBeDefined()
|
||||
expect(optimisticFolder?.parentId).toBe('parent-1')
|
||||
expect(optimisticFolder?.sortOrder).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createFolderContract,
|
||||
deleteFolderContract,
|
||||
duplicateFolderContract,
|
||||
type FolderApi,
|
||||
listFoldersContract,
|
||||
reorderFoldersContract,
|
||||
restoreFolderContract,
|
||||
updateFolderContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
generateTempId,
|
||||
} from '@/hooks/queries/utils/optimistic-mutation'
|
||||
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const logger = createLogger('FolderQueries')
|
||||
|
||||
export const FOLDER_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Maps a wire folder row to the client `WorkflowFolder` shape (string dates →
|
||||
* `Date`, color default). Exported so the server-side home prefetch produces
|
||||
* the exact cached value `useFolders` stores, keeping the hydrated entry in
|
||||
* sync with a client fetch.
|
||||
*/
|
||||
export function mapFolder(folder: FolderApi): WorkflowFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
userId: folder.userId,
|
||||
workspaceId: folder.workspaceId,
|
||||
parentId: folder.parentId,
|
||||
color: folder.color ?? '#6B7280',
|
||||
isExpanded: folder.isExpanded,
|
||||
locked: folder.locked,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: new Date(folder.createdAt),
|
||||
updatedAt: new Date(folder.updatedAt),
|
||||
archivedAt: folder.archivedAt ? new Date(folder.archivedAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFolders(
|
||||
workspaceId: string,
|
||||
scope: FolderQueryScope = 'active',
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowFolder[]> {
|
||||
const { folders } = await requestJson(listFoldersContract, {
|
||||
query: { workspaceId, scope },
|
||||
signal,
|
||||
})
|
||||
return folders.map(mapFolder)
|
||||
}
|
||||
|
||||
export function useFolders(workspaceId?: string, options?: { scope?: FolderQueryScope }) {
|
||||
const scope = options?.scope ?? 'active'
|
||||
return useQuery({
|
||||
queryKey: folderKeys.list(workspaceId, scope),
|
||||
queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: FOLDER_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
const selectFolderMap = (folders: WorkflowFolder[]): Record<string, WorkflowFolder> =>
|
||||
Object.fromEntries(folders.map((folder) => [folder.id, folder]))
|
||||
|
||||
export function useFolderMap(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: folderKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: FOLDER_LIST_STALE_TIME,
|
||||
select: selectFolderMap,
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateFolderVariables {
|
||||
workspaceId: string
|
||||
name: string
|
||||
parentId?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface UpdateFolderVariables {
|
||||
workspaceId: string
|
||||
id: string
|
||||
updates: Partial<Pick<WorkflowFolder, 'name' | 'parentId' | 'color' | 'sortOrder' | 'locked'>>
|
||||
}
|
||||
|
||||
interface DeleteFolderVariables {
|
||||
workspaceId: string
|
||||
id: string
|
||||
}
|
||||
|
||||
interface DuplicateFolderVariables {
|
||||
workspaceId: string
|
||||
id: string
|
||||
name: string
|
||||
parentId?: string | null
|
||||
color?: string
|
||||
newId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optimistic mutation handlers for folder operations
|
||||
*/
|
||||
function createFolderMutationHandlers<TVariables extends { workspaceId: string }>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
name: string,
|
||||
createOptimisticFolder: (
|
||||
variables: TVariables,
|
||||
tempId: string,
|
||||
previousFolders: Record<string, WorkflowFolder>
|
||||
) => WorkflowFolder,
|
||||
customGenerateTempId?: (variables: TVariables) => string
|
||||
) {
|
||||
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(queryClient, {
|
||||
name,
|
||||
getQueryKey: (variables) => folderKeys.list(variables.workspaceId),
|
||||
getSnapshot: (variables) => ({ ...getFolderMap(variables.workspaceId) }),
|
||||
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')),
|
||||
createOptimisticItem: (variables, tempId) => {
|
||||
const previousFolders = getFolderMap(variables.workspaceId)
|
||||
return createOptimisticFolder(variables, tempId, previousFolders)
|
||||
},
|
||||
applyOptimisticUpdate: (tempId, item) => {
|
||||
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(item.workspaceId), (old) => [
|
||||
...(old ?? []),
|
||||
item,
|
||||
])
|
||||
},
|
||||
replaceOptimisticEntry: (tempId, data) => {
|
||||
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(data.workspaceId), (old) =>
|
||||
(old ?? []).map((folder) => (folder.id === tempId ? data : folder))
|
||||
)
|
||||
},
|
||||
rollback: (snapshot, variables) => {
|
||||
queryClient.setQueryData(folderKeys.list(variables.workspaceId), Object.values(snapshot))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createFolderMutationHandlers<CreateFolderVariables>(
|
||||
queryClient,
|
||||
'CreateFolder',
|
||||
(variables, tempId, previousFolders) => {
|
||||
const currentWorkflows = Object.fromEntries(
|
||||
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
|
||||
)
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
userId: '',
|
||||
workspaceId: variables.workspaceId,
|
||||
parentId: variables.parentId || null,
|
||||
color: variables.color || '#808080',
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
sortOrder:
|
||||
variables.sortOrder ??
|
||||
getTopInsertionSortOrder(
|
||||
currentWorkflows,
|
||||
previousFolders,
|
||||
variables.workspaceId,
|
||||
variables.parentId
|
||||
),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
archivedAt: null,
|
||||
}
|
||||
},
|
||||
(variables) => variables.id ?? generateId()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
|
||||
const { folder } = await requestJson(createFolderContract, {
|
||||
body: { ...payload, workspaceId, sortOrder },
|
||||
})
|
||||
return mapFolder(folder)
|
||||
},
|
||||
...handlers,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, id, updates }: UpdateFolderVariables) => {
|
||||
const { folder } = await requestJson(updateFolderContract, {
|
||||
params: { id },
|
||||
body: updates,
|
||||
})
|
||||
return mapFolder(folder)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFolderMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId: _workspaceId, id }: DeleteFolderVariables) => {
|
||||
return requestJson(deleteFolderContract, { params: { id } })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface RestoreFolderVariables {
|
||||
workspaceId: string
|
||||
folderId: string
|
||||
}
|
||||
|
||||
export function useRestoreFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, folderId }: RestoreFolderVariables) => {
|
||||
return requestJson(restoreFolderContract, {
|
||||
params: { id: folderId },
|
||||
body: { workspaceId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDuplicateFolderMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createFolderMutationHandlers<DuplicateFolderVariables>(
|
||||
queryClient,
|
||||
'DuplicateFolder',
|
||||
(variables, tempId, previousFolders) => {
|
||||
const currentWorkflows = Object.fromEntries(
|
||||
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
|
||||
)
|
||||
|
||||
const sourceFolder = previousFolders[variables.id]
|
||||
const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
userId: sourceFolder?.userId || '',
|
||||
workspaceId: variables.workspaceId,
|
||||
parentId: targetParentId,
|
||||
color: variables.color || sourceFolder?.color || '#808080',
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
sortOrder: getTopInsertionSortOrder(
|
||||
currentWorkflows,
|
||||
previousFolders,
|
||||
variables.workspaceId,
|
||||
targetParentId
|
||||
),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
archivedAt: null,
|
||||
}
|
||||
},
|
||||
(variables) => variables.newId ?? generateId()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
workspaceId,
|
||||
name,
|
||||
parentId,
|
||||
color,
|
||||
newId,
|
||||
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
|
||||
const { folder } = await requestJson(duplicateFolderContract, {
|
||||
params: { id },
|
||||
body: {
|
||||
workspaceId,
|
||||
name,
|
||||
parentId: parentId ?? null,
|
||||
color,
|
||||
newId,
|
||||
},
|
||||
})
|
||||
return mapFolder(folder)
|
||||
},
|
||||
...handlers,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderFoldersVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
parentId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderFolders() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderFoldersVariables): Promise<void> => {
|
||||
await requestJson(reorderFoldersContract, { body: variables })
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowFolder[]>(
|
||||
folderKeys.list(variables.workspaceId)
|
||||
)
|
||||
|
||||
const updatesById = new Map(variables.updates.map((update) => [update.id, update]))
|
||||
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(variables.workspaceId), (old) => {
|
||||
if (!old?.length) return old
|
||||
return old.map((folder) => {
|
||||
const update = updatesById.get(folder.id)
|
||||
if (!update) return folder
|
||||
return {
|
||||
...folder,
|
||||
sortOrder: update.sortOrder,
|
||||
parentId: update.parentId !== undefined ? update.parentId : folder.parentId,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(folderKeys.list(variables.workspaceId), context.snapshot)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getUserSettingsContract,
|
||||
type MothershipEnvironment,
|
||||
type UserSettingsApi,
|
||||
updateUserSettingsContract,
|
||||
} from '@/lib/api/contracts/user'
|
||||
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
|
||||
import { getBrowserTimezone } from '@/lib/core/utils/timezone'
|
||||
|
||||
const logger = createLogger('GeneralSettingsQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for general settings
|
||||
*/
|
||||
export const generalSettingsKeys = {
|
||||
all: ['generalSettings'] as const,
|
||||
settings: () => [...generalSettingsKeys.all, 'settings'] as const,
|
||||
}
|
||||
|
||||
export const GENERAL_SETTINGS_STALE_TIME = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* General settings type
|
||||
*/
|
||||
export interface GeneralSettings {
|
||||
autoConnect: boolean
|
||||
showTrainingControls: boolean
|
||||
superUserModeEnabled: boolean
|
||||
mothershipEnvironment: MothershipEnvironment
|
||||
theme: 'light' | 'dark' | 'system'
|
||||
telemetryEnabled: boolean
|
||||
billingUsageNotificationsEnabled: boolean
|
||||
errorNotificationsEnabled: boolean
|
||||
snapToGridSize: number
|
||||
showActionBar: boolean
|
||||
/** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */
|
||||
timezone: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw API response data to GeneralSettings with defaults.
|
||||
* Shared by both client fetch and server prefetch to prevent shape drift.
|
||||
*/
|
||||
export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettings {
|
||||
return {
|
||||
autoConnect: data.autoConnect,
|
||||
showTrainingControls: data.showTrainingControls,
|
||||
superUserModeEnabled: data.superUserModeEnabled,
|
||||
mothershipEnvironment: data.mothershipEnvironment,
|
||||
theme: data.theme,
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled,
|
||||
errorNotificationsEnabled: data.errorNotificationsEnabled,
|
||||
snapToGridSize: data.snapToGridSize,
|
||||
showActionBar: data.showActionBar,
|
||||
timezone: data.timezone ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch general settings from API
|
||||
*/
|
||||
async function fetchGeneralSettings(signal?: AbortSignal): Promise<GeneralSettings> {
|
||||
const { data } = await requestJson(getUserSettingsContract, { signal })
|
||||
return mapGeneralSettingsResponse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch general settings.
|
||||
* TanStack Query is now the single source of truth for general settings.
|
||||
*/
|
||||
export function useGeneralSettings() {
|
||||
return useQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const settings = await fetchGeneralSettings(signal)
|
||||
syncThemeToNextThemes(settings.theme)
|
||||
return settings
|
||||
},
|
||||
staleTime: GENERAL_SETTINGS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch general settings into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchGeneralSettings(queryClient: QueryClient) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const settings = await fetchGeneralSettings(signal)
|
||||
syncThemeToNextThemes(settings.theme)
|
||||
return settings
|
||||
},
|
||||
staleTime: GENERAL_SETTINGS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience selector hooks for individual settings.
|
||||
* These provide a simple API for components that only need a single setting value.
|
||||
*/
|
||||
|
||||
export function useAutoConnect(): boolean {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.autoConnect ?? true
|
||||
}
|
||||
|
||||
export function useShowTrainingControls(): boolean {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.showTrainingControls ?? false
|
||||
}
|
||||
|
||||
export function useSnapToGridSize(): number {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.snapToGridSize ?? 0
|
||||
}
|
||||
|
||||
export function useShowActionBar(): boolean {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.showActionBar ?? true
|
||||
}
|
||||
|
||||
export function useBillingUsageNotifications(): boolean {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.billingUsageNotificationsEnabled ?? true
|
||||
}
|
||||
|
||||
export function useErrorNotificationsEnabled(): boolean {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.errorNotificationsEnabled ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's effective scheduling timezone: their saved preference, or the
|
||||
* browser-detected zone when unset. Use this wherever a task's timezone is
|
||||
* captured so scheduling honors the account preference rather than the device.
|
||||
*/
|
||||
export function useTimezone(): string {
|
||||
const { data } = useGeneralSettings()
|
||||
return data?.timezone ?? getBrowserTimezone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update general settings mutation
|
||||
*/
|
||||
type UpdateSettingParams = {
|
||||
[K in keyof GeneralSettings]: {
|
||||
key: K
|
||||
value: GeneralSettings[K]
|
||||
}
|
||||
}[keyof GeneralSettings]
|
||||
|
||||
export function useUpdateGeneralSetting() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ key, value }: UpdateSettingParams) => {
|
||||
return requestJson(updateUserSettingsContract, { body: { [key]: value } })
|
||||
},
|
||||
onMutate: async ({ key, value }) => {
|
||||
await queryClient.cancelQueries({ queryKey: generalSettingsKeys.settings() })
|
||||
|
||||
const previousSettings = queryClient.getQueryData<GeneralSettings>(
|
||||
generalSettingsKeys.settings()
|
||||
)
|
||||
|
||||
if (previousSettings) {
|
||||
const newSettings = {
|
||||
...previousSettings,
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
|
||||
|
||||
if (key === 'theme') {
|
||||
syncThemeToNextThemes(value as GeneralSettings['theme'])
|
||||
}
|
||||
}
|
||||
|
||||
return { previousSettings }
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousSettings) {
|
||||
queryClient.setQueryData(generalSettingsKeys.settings(), context.previousSettings)
|
||||
syncThemeToNextThemes(context.previousSettings.theme)
|
||||
}
|
||||
logger.error('Failed to update setting:', err)
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getStarsContract } from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* Query key factory for GitHub stars queries
|
||||
*/
|
||||
export const githubStarsKeys = {
|
||||
all: ['githubStars'] as const,
|
||||
count: () => [...githubStarsKeys.all, 'count'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback star count shown before the first fetch resolves. Centralized here
|
||||
* so every consumer of `useGitHubStars` renders the same placeholder.
|
||||
*/
|
||||
export const GITHUB_STARS_FALLBACK = '27.8k'
|
||||
|
||||
export const GITHUB_STARS_STALE_TIME = 60 * 60 * 1000
|
||||
|
||||
async function fetchGitHubStars(signal?: AbortSignal): Promise<string> {
|
||||
const data = await requestJson(getStarsContract, { signal })
|
||||
const value = data.stars
|
||||
return typeof value === 'string' && value.length > 0 ? value : GITHUB_STARS_FALLBACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the formatted GitHub star count for the Sim repository.
|
||||
* The `/api/stars` endpoint caches upstream for 1 hour, so a 1-hour
|
||||
* staleTime keeps the client cache aligned with the server cache.
|
||||
* `initialData` + `initialDataUpdatedAt: 0` gives `data` a narrowed
|
||||
* `string` type from the first render while still triggering a refetch
|
||||
* on mount, so consumers never need a fallback or undefined check.
|
||||
*/
|
||||
export function useGitHubStars() {
|
||||
return useQuery({
|
||||
queryKey: githubStarsKeys.count(),
|
||||
queryFn: ({ signal }) => fetchGitHubStars(signal),
|
||||
staleTime: GITHUB_STARS_STALE_TIME,
|
||||
initialData: GITHUB_STARS_FALLBACK,
|
||||
initialDataUpdatedAt: 0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
addInboxSenderContract,
|
||||
getInboxConfigContract,
|
||||
type InboxConfig,
|
||||
type InboxSendersResponseBody,
|
||||
type InboxTask,
|
||||
type InboxTaskStatus,
|
||||
type InboxTasksResponseBody,
|
||||
listInboxSendersContract,
|
||||
listInboxTasksContract,
|
||||
removeInboxSenderContract,
|
||||
updateInboxConfigContract,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
export type { InboxConfig, InboxSendersResponseBody }
|
||||
export type InboxTaskItem = InboxTask
|
||||
export type InboxTasksResponse = InboxTasksResponseBody
|
||||
|
||||
export const INBOX_CONFIG_STALE_TIME = 30 * 1000
|
||||
export const INBOX_SENDER_LIST_STALE_TIME = 60 * 1000
|
||||
export const INBOX_TASK_LIST_STALE_TIME = 15 * 1000
|
||||
|
||||
export const inboxKeys = {
|
||||
all: ['inbox'] as const,
|
||||
configs: () => [...inboxKeys.all, 'config'] as const,
|
||||
config: (workspaceId: string) => [...inboxKeys.configs(), workspaceId] as const,
|
||||
senders: () => [...inboxKeys.all, 'sender'] as const,
|
||||
senderList: (workspaceId: string) => [...inboxKeys.senders(), workspaceId] as const,
|
||||
tasks: () => [...inboxKeys.all, 'task'] as const,
|
||||
taskList: (workspaceId: string, status?: string, cursor?: string, limit?: number) =>
|
||||
[...inboxKeys.tasks(), workspaceId, status ?? 'all', cursor ?? '', limit ?? ''] as const,
|
||||
}
|
||||
|
||||
type InboxTaskStatusFilter = InboxTaskStatus
|
||||
|
||||
async function fetchInboxConfig(workspaceId: string, signal?: AbortSignal): Promise<InboxConfig> {
|
||||
return requestJson(getInboxConfigContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchInboxSenders(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<InboxSendersResponseBody> {
|
||||
return requestJson(listInboxSendersContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchInboxTasks(
|
||||
workspaceId: string,
|
||||
opts: { status?: InboxTaskStatusFilter; cursor?: string; limit?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<InboxTasksResponseBody> {
|
||||
return requestJson(listInboxTasksContract, {
|
||||
params: { id: workspaceId },
|
||||
query: {
|
||||
status: opts.status && opts.status !== 'all' ? opts.status : undefined,
|
||||
cursor: opts.cursor,
|
||||
limit: opts.limit,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInboxConfig(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: inboxKeys.config(workspaceId),
|
||||
queryFn: ({ signal }) => fetchInboxConfig(workspaceId, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: INBOX_CONFIG_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInboxSenders(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: inboxKeys.senderList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchInboxSenders(workspaceId, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: INBOX_SENDER_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInboxTasks(
|
||||
workspaceId: string,
|
||||
opts: { status?: InboxTaskStatusFilter; cursor?: string; limit?: number } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: inboxKeys.taskList(workspaceId, opts.status, opts.cursor, opts.limit),
|
||||
queryFn: ({ signal }) => fetchInboxTasks(workspaceId, opts, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: INBOX_TASK_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleInbox() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
enabled,
|
||||
username,
|
||||
}: {
|
||||
workspaceId: string
|
||||
enabled: boolean
|
||||
username?: string
|
||||
}) => {
|
||||
return requestJson(updateInboxConfigContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { enabled, username },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateInboxAddress() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, username }: { workspaceId: string; username: string }) => {
|
||||
return requestJson(updateInboxConfigContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { username },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddInboxSender() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
email,
|
||||
label,
|
||||
}: {
|
||||
workspaceId: string
|
||||
email: string
|
||||
label?: string
|
||||
}) => {
|
||||
return requestJson(addInboxSenderContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { email, label },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ workspaceId, email, label }) => {
|
||||
await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) })
|
||||
const previous = queryClient.getQueryData<InboxSendersResponseBody>(
|
||||
inboxKeys.senderList(workspaceId)
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData<InboxSendersResponseBody>(inboxKeys.senderList(workspaceId), {
|
||||
...previous,
|
||||
senders: [
|
||||
...previous.senders,
|
||||
{
|
||||
id: `optimistic-${generateId()}`,
|
||||
email,
|
||||
label: label || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(inboxKeys.senderList(variables.workspaceId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: inboxKeys.senderList(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveInboxSender() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, senderId }: { workspaceId: string; senderId: string }) => {
|
||||
return requestJson(removeInboxSenderContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { senderId },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ workspaceId, senderId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) })
|
||||
const previous = queryClient.getQueryData<InboxSendersResponseBody>(
|
||||
inboxKeys.senderList(workspaceId)
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData<InboxSendersResponseBody>(inboxKeys.senderList(workspaceId), {
|
||||
...previous,
|
||||
senders: previous.senders.filter((s) => s.id !== senderId),
|
||||
})
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(inboxKeys.senderList(variables.workspaceId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: inboxKeys.senderList(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
type BatchInvitationResult as BatchInvitationResultContract,
|
||||
batchWorkspaceInvitationsContract,
|
||||
cancelInvitationContract,
|
||||
listWorkspaceInvitationsContract,
|
||||
type PendingInvitationRow,
|
||||
removeWorkspaceMemberContract,
|
||||
resendInvitationContract,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
export const invitationKeys = {
|
||||
all: ['invitations'] as const,
|
||||
lists: () => [...invitationKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
|
||||
}
|
||||
|
||||
export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export interface WorkspaceInvitation {
|
||||
email: string
|
||||
permissionType: 'admin' | 'write' | 'read'
|
||||
isPendingInvitation: boolean
|
||||
isExternal: boolean
|
||||
invitationId?: string
|
||||
token: string
|
||||
}
|
||||
|
||||
async function fetchPendingInvitations(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceInvitation[]> {
|
||||
const data = await requestJson(listWorkspaceInvitationsContract, { signal })
|
||||
|
||||
return (
|
||||
data.invitations
|
||||
?.filter(
|
||||
(inv: PendingInvitationRow) => inv.status === 'pending' && inv.workspaceId === workspaceId
|
||||
)
|
||||
.map((inv: PendingInvitationRow) => ({
|
||||
email: inv.email,
|
||||
permissionType: inv.permission,
|
||||
isPendingInvitation: true,
|
||||
isExternal: inv.membershipIntent === 'external',
|
||||
invitationId: inv.id,
|
||||
token: inv.token,
|
||||
})) || []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ({ signal }) => fetchPendingInvitations(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
type BatchSendInvitationsParams = ContractBodyInput<typeof batchWorkspaceInvitationsContract> & {
|
||||
organizationId?: string | null
|
||||
}
|
||||
|
||||
type BatchInvitationResult = Pick<BatchInvitationResultContract, 'successful' | 'failed'> & {
|
||||
added: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends workspace invitations through the server-side batch endpoint.
|
||||
* Returns results for each invitation indicating success or failure. Existing
|
||||
* organization members are added directly (no acceptance) and reported in
|
||||
* `added`; everyone else receives a pending invitation in `successful`.
|
||||
*/
|
||||
export function useBatchSendWorkspaceInvitations() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
invitations,
|
||||
}: BatchSendInvitationsParams): Promise<BatchInvitationResult> => {
|
||||
const result = await requestJson(batchWorkspaceInvitationsContract, {
|
||||
body: {
|
||||
workspaceId,
|
||||
invitations,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
successful: result.successful ?? [],
|
||||
added: result.added ?? [],
|
||||
failed: result.failed ?? [],
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.members(variables.workspaceId),
|
||||
})
|
||||
if (variables.organizationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.roster(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.organizationId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface CancelInvitationParams {
|
||||
invitationId: string
|
||||
workspaceId: string
|
||||
organizationId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a pending workspace invitation.
|
||||
* Invalidates the invitation list cache on success.
|
||||
*/
|
||||
export function useCancelWorkspaceInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
|
||||
return requestJson(cancelInvitationContract, {
|
||||
params: { id: invitationId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
if (variables.organizationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.roster(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.organizationId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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) => {
|
||||
return requestJson(resendInvitationContract, {
|
||||
params: { id: invitationId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type RemoveMemberParams = ContractBodyInput<typeof removeWorkspaceMemberContract> & {
|
||||
userId: string
|
||||
organizationId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
return requestJson(removeWorkspaceMemberContract, {
|
||||
params: { id: userId },
|
||||
body: { workspaceId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.members(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.all,
|
||||
})
|
||||
if (variables.organizationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.roster(variables.organizationId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type LeaveWorkspaceParams = ContractBodyInput<typeof removeWorkspaceMemberContract> & {
|
||||
userId: 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) => {
|
||||
return requestJson(removeWorkspaceMemberContract, {
|
||||
params: { id: userId },
|
||||
body: { workspaceId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.detail(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type UpdatePermissionsParams = {
|
||||
workspaceId: string
|
||||
organizationId?: string
|
||||
} & ContractBodyInput<typeof updateWorkspacePermissionsContract>
|
||||
|
||||
export function useUpdateWorkspacePermissions() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => {
|
||||
return requestJson(updateWorkspacePermissionsContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { updates },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
if (variables.organizationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.roster(variables.organizationId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ConnectorData,
|
||||
type ConnectorDetailData,
|
||||
type ConnectorDocumentsData,
|
||||
createKnowledgeConnectorContract,
|
||||
deleteKnowledgeConnectorContract,
|
||||
getKnowledgeConnectorContract,
|
||||
listKnowledgeConnectorDocumentsContract,
|
||||
listKnowledgeConnectorsContract,
|
||||
patchKnowledgeConnectorDocumentsContract,
|
||||
type SyncLogData,
|
||||
triggerKnowledgeConnectorSyncContract,
|
||||
updateKnowledgeConnectorContract,
|
||||
} from '@/lib/api/contracts/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorQueries')
|
||||
|
||||
export type { ConnectorData, ConnectorDetailData, SyncLogData }
|
||||
|
||||
export const CONNECTOR_LIST_STALE_TIME = 30 * 1000
|
||||
export const CONNECTOR_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const CONNECTOR_DOCUMENT_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export const connectorKeys = {
|
||||
all: (knowledgeBaseId?: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors'] as const,
|
||||
lists: (knowledgeBaseId?: string) => [...connectorKeys.all(knowledgeBaseId), 'list'] as const,
|
||||
list: (knowledgeBaseId?: string) => connectorKeys.lists(knowledgeBaseId),
|
||||
details: (knowledgeBaseId?: string) => [...connectorKeys.all(knowledgeBaseId), 'detail'] as const,
|
||||
detail: (knowledgeBaseId?: string, connectorId?: string) =>
|
||||
[...connectorKeys.details(knowledgeBaseId), connectorId ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchConnectors(
|
||||
knowledgeBaseId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ConnectorData[]> {
|
||||
const result = await requestJson(listKnowledgeConnectorsContract, {
|
||||
params: { id: knowledgeBaseId },
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function fetchConnectorDetail(
|
||||
knowledgeBaseId: string,
|
||||
connectorId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ConnectorDetailData> {
|
||||
const result = await requestJson(getKnowledgeConnectorContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/** Stop polling for initial sync after 2 minutes */
|
||||
const PENDING_SYNC_WINDOW_MS = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Checks if a connector is syncing or awaiting its first sync within the allowed window
|
||||
*/
|
||||
export function isConnectorSyncingOrPending(connector: ConnectorData): boolean {
|
||||
if (connector.status === 'syncing') return true
|
||||
return (
|
||||
connector.status === 'active' &&
|
||||
!connector.lastSyncAt &&
|
||||
Date.now() - new Date(connector.createdAt).getTime() < PENDING_SYNC_WINDOW_MS
|
||||
)
|
||||
}
|
||||
|
||||
export function useConnectorList(knowledgeBaseId?: string) {
|
||||
return useQuery({
|
||||
queryKey: connectorKeys.list(knowledgeBaseId),
|
||||
queryFn: ({ signal }) => fetchConnectors(knowledgeBaseId as string, signal),
|
||||
enabled: Boolean(knowledgeBaseId),
|
||||
staleTime: CONNECTOR_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (query) => {
|
||||
const connectors = query.state.data
|
||||
if (!connectors?.length) return false
|
||||
return connectors.some(isConnectorSyncingOrPending) ? 3000 : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useConnectorDetail(knowledgeBaseId?: string, connectorId?: string) {
|
||||
return useQuery({
|
||||
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchConnectorDetail(knowledgeBaseId as string, connectorId as string, signal),
|
||||
enabled: Boolean(knowledgeBaseId && connectorId),
|
||||
staleTime: CONNECTOR_DETAIL_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateConnectorParams {
|
||||
knowledgeBaseId: string
|
||||
connectorType: string
|
||||
credentialId?: string
|
||||
apiKey?: string
|
||||
sourceConfig: Record<string, unknown>
|
||||
syncIntervalMinutes?: number
|
||||
}
|
||||
|
||||
async function createConnector({
|
||||
knowledgeBaseId,
|
||||
...body
|
||||
}: CreateConnectorParams): Promise<ConnectorData> {
|
||||
const result = await requestJson(createKnowledgeConnectorContract, {
|
||||
params: { id: knowledgeBaseId },
|
||||
body,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function useCreateConnector() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createConnector,
|
||||
onSettled: (_data, _error, { knowledgeBaseId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateConnectorParams {
|
||||
knowledgeBaseId: string
|
||||
connectorId: string
|
||||
updates: {
|
||||
sourceConfig?: Record<string, unknown>
|
||||
syncIntervalMinutes?: number
|
||||
status?: 'active' | 'paused'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConnector({
|
||||
knowledgeBaseId,
|
||||
connectorId,
|
||||
updates,
|
||||
}: UpdateConnectorParams): Promise<ConnectorData> {
|
||||
const result = await requestJson(updateKnowledgeConnectorContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
body: updates,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function useUpdateConnector() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateConnector,
|
||||
onSettled: (_data, _error, { knowledgeBaseId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: connectorKeys.all(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DeleteConnectorParams {
|
||||
knowledgeBaseId: string
|
||||
connectorId: string
|
||||
deleteDocuments?: boolean
|
||||
}
|
||||
|
||||
async function deleteConnector({
|
||||
knowledgeBaseId,
|
||||
connectorId,
|
||||
deleteDocuments,
|
||||
}: DeleteConnectorParams): Promise<void> {
|
||||
await requestJson(deleteKnowledgeConnectorContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
query: { deleteDocuments },
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteConnector() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteConnector,
|
||||
onSettled: (_data, _error, { knowledgeBaseId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface TriggerSyncParams {
|
||||
knowledgeBaseId: string
|
||||
connectorId: string
|
||||
}
|
||||
|
||||
async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise<void> {
|
||||
await requestJson(triggerKnowledgeConnectorSyncContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
})
|
||||
}
|
||||
|
||||
export function useTriggerSync() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: triggerSync,
|
||||
onSettled: (_data, _error, { knowledgeBaseId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const connectorDocumentKeys = {
|
||||
all: (knowledgeBaseId?: string, connectorId?: string) =>
|
||||
[...connectorKeys.detail(knowledgeBaseId, connectorId), 'documents'] as const,
|
||||
lists: (knowledgeBaseId?: string, connectorId?: string) =>
|
||||
[...connectorDocumentKeys.all(knowledgeBaseId, connectorId), 'list'] as const,
|
||||
list: (knowledgeBaseId?: string, connectorId?: string) =>
|
||||
connectorDocumentKeys.lists(knowledgeBaseId, connectorId),
|
||||
}
|
||||
|
||||
async function fetchConnectorDocuments(
|
||||
knowledgeBaseId: string,
|
||||
connectorId: string,
|
||||
includeExcluded: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<ConnectorDocumentsData> {
|
||||
const result = await requestJson(listKnowledgeConnectorDocumentsContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
query: { includeExcluded },
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function useConnectorDocuments(
|
||||
knowledgeBaseId?: string,
|
||||
connectorId?: string,
|
||||
options?: { includeExcluded?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...connectorDocumentKeys.list(knowledgeBaseId, connectorId),
|
||||
options?.includeExcluded ?? false,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchConnectorDocuments(
|
||||
knowledgeBaseId as string,
|
||||
connectorId as string,
|
||||
options?.includeExcluded ?? false,
|
||||
signal
|
||||
),
|
||||
enabled: Boolean(knowledgeBaseId && connectorId),
|
||||
staleTime: CONNECTOR_DOCUMENT_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
interface ConnectorDocumentMutationParams {
|
||||
knowledgeBaseId: string
|
||||
connectorId: string
|
||||
documentIds: string[]
|
||||
}
|
||||
|
||||
async function excludeConnectorDocuments({
|
||||
knowledgeBaseId,
|
||||
connectorId,
|
||||
documentIds,
|
||||
}: ConnectorDocumentMutationParams): Promise<{ excludedCount: number }> {
|
||||
const result = await requestJson(patchKnowledgeConnectorDocumentsContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
body: { operation: 'exclude', documentIds },
|
||||
})
|
||||
|
||||
return { excludedCount: result.data.excludedCount ?? 0 }
|
||||
}
|
||||
|
||||
export function useExcludeConnectorDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: excludeConnectorDocuments,
|
||||
onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreConnectorDocuments({
|
||||
knowledgeBaseId,
|
||||
connectorId,
|
||||
documentIds,
|
||||
}: ConnectorDocumentMutationParams): Promise<{ restoredCount: number }> {
|
||||
const result = await requestJson(patchKnowledgeConnectorDocumentsContract, {
|
||||
params: { id: knowledgeBaseId, connectorId },
|
||||
body: { operation: 'restore', documentIds },
|
||||
})
|
||||
|
||||
return { restoredCount: result.data.restoredCount ?? 0 }
|
||||
}
|
||||
|
||||
export function useRestoreConnectorDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: restoreConnectorDocuments,
|
||||
onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
type InfiniteData,
|
||||
keepPreviousData,
|
||||
type QueryClient,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
cancelWorkflowExecutionContract,
|
||||
type DashboardStatsResponse,
|
||||
type ExecutionSnapshotData,
|
||||
getDashboardStatsContract,
|
||||
getExecutionSnapshotContract,
|
||||
getLogByExecutionIdContract,
|
||||
getLogDetailContract,
|
||||
listLogsContract,
|
||||
type WorkflowLogDetail,
|
||||
type WorkflowLogSummary,
|
||||
type WorkflowStats,
|
||||
} from '@/lib/api/contracts/logs'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import type { TimeRange } from '@/stores/logs/filters/types'
|
||||
|
||||
export type { DashboardStatsResponse, WorkflowStats }
|
||||
|
||||
export type LogSortBy = 'date' | 'duration' | 'cost' | 'status'
|
||||
export type LogSortOrder = 'asc' | 'desc'
|
||||
|
||||
export const LOG_LIST_STALE_TIME = 30 * 1000
|
||||
export const LOG_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const LOG_BY_EXECUTION_STALE_TIME = 30 * 1000
|
||||
export const LOG_DASHBOARD_STATS_STALE_TIME = 30 * 1000
|
||||
export const EXECUTION_SNAPSHOT_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const logKeys = {
|
||||
all: ['logs'] as const,
|
||||
lists: () => [...logKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, filters: LogFilters) =>
|
||||
[...logKeys.lists(), workspaceId ?? '', filters] as const,
|
||||
details: () => [...logKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId: string | undefined, logId: string | undefined) =>
|
||||
[...logKeys.details(), workspaceId ?? '', logId ?? ''] as const,
|
||||
byExecutionAll: () => [...logKeys.all, 'byExecution'] as const,
|
||||
byExecution: (workspaceId: string | undefined, executionId: string | undefined) =>
|
||||
[...logKeys.byExecutionAll(), workspaceId ?? '', executionId ?? ''] as const,
|
||||
stats: () => [...logKeys.all, 'stats'] as const,
|
||||
stat: (workspaceId: string | undefined, filters: object) =>
|
||||
[...logKeys.stats(), workspaceId ?? '', filters] as const,
|
||||
executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const,
|
||||
executionSnapshot: (executionId: string | undefined) =>
|
||||
[...logKeys.executionSnapshots(), executionId ?? ''] as const,
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
timeRange: TimeRange
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
level: string
|
||||
workflowIds: string[]
|
||||
folderIds: string[]
|
||||
triggers: string[]
|
||||
searchQuery: string
|
||||
limit: number
|
||||
sortBy: LogSortBy
|
||||
sortOrder: LogSortOrder
|
||||
}
|
||||
|
||||
function applyFilterParams(
|
||||
params: URLSearchParams,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>
|
||||
): void {
|
||||
if (filters.level !== 'all') {
|
||||
params.set('level', filters.level)
|
||||
}
|
||||
|
||||
if (filters.triggers.length > 0) {
|
||||
params.set('triggers', filters.triggers.join(','))
|
||||
}
|
||||
|
||||
if (filters.workflowIds.length > 0) {
|
||||
params.set('workflowIds', filters.workflowIds.join(','))
|
||||
}
|
||||
|
||||
if (filters.folderIds.length > 0) {
|
||||
params.set('folderIds', filters.folderIds.join(','))
|
||||
}
|
||||
|
||||
const startDate = getStartDateFromTimeRange(filters.timeRange, filters.startDate)
|
||||
if (startDate) {
|
||||
params.set('startDate', startDate.toISOString())
|
||||
}
|
||||
|
||||
const endDate = getEndDateFromTimeRange(filters.timeRange, filters.endDate)
|
||||
if (endDate) {
|
||||
params.set('endDate', endDate.toISOString())
|
||||
}
|
||||
|
||||
if (filters.searchQuery.trim()) {
|
||||
const parsedQuery = parseQuery(filters.searchQuery.trim())
|
||||
const searchParams = queryToApiParams(parsedQuery)
|
||||
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
params.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildListQuery(workspaceId: string, filters: LogFilters, cursor: string | null) {
|
||||
const params = new URLSearchParams()
|
||||
applyFilterParams(params, filters)
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
limit: filters.limit,
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...Object.fromEntries(params.entries()),
|
||||
}
|
||||
}
|
||||
|
||||
interface LogsPage {
|
||||
logs: WorkflowLogSummary[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
async function fetchLogsPage(
|
||||
workspaceId: string,
|
||||
filters: LogFilters,
|
||||
cursor: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LogsPage> {
|
||||
const apiData = await requestJson(listLogsContract, {
|
||||
query: buildListQuery(workspaceId, filters, cursor),
|
||||
signal,
|
||||
})
|
||||
|
||||
return {
|
||||
logs: apiData.data,
|
||||
nextCursor: apiData.nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLogDetail(
|
||||
logId: string,
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowLogDetail> {
|
||||
const { data } = await requestJson(getLogDetailContract, {
|
||||
params: { id: logId },
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
interface UseLogsListOptions {
|
||||
enabled?: boolean
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
|
||||
export function useLogsList(
|
||||
workspaceId: string | undefined,
|
||||
filters: LogFilters,
|
||||
options?: UseLogsListOptions
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: logKeys.list(workspaceId, filters),
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
fetchLogsPage(workspaceId as string, filters, pageParam, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: LOG_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseLogDetailOptions {
|
||||
enabled?: boolean
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((query: { state: { data?: WorkflowLogDetail } }) => number | false | undefined)
|
||||
}
|
||||
|
||||
export function useLogDetail(
|
||||
logId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
options?: UseLogDetailOptions
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.detail(workspaceId, logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId as string, workspaceId as string, signal),
|
||||
enabled: Boolean(logId) && Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: LOG_DETAIL_STALE_TIME,
|
||||
retry: (failureCount, err) =>
|
||||
!(isApiClientError(err) && err.status === 404) && failureCount < 3,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogByExecutionId(
|
||||
workspaceId: string | undefined,
|
||||
executionId: string | null | undefined
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: logKeys.byExecution(workspaceId, executionId ?? undefined),
|
||||
queryFn: async ({ signal }) => {
|
||||
const { data } = await requestJson(getLogByExecutionIdContract, {
|
||||
params: { executionId: executionId as string },
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
})
|
||||
queryClient.setQueryData(logKeys.detail(workspaceId, data.id), data)
|
||||
return data
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(executionId),
|
||||
staleTime: LOG_BY_EXECUTION_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function prefetchLogDetail(queryClient: QueryClient, logId: string, workspaceId: string) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: logKeys.detail(workspaceId, logId),
|
||||
queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
|
||||
staleTime: LOG_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchDashboardStats(
|
||||
workspaceId: string,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>,
|
||||
signal?: AbortSignal
|
||||
): Promise<DashboardStatsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
applyFilterParams(params, filters)
|
||||
|
||||
return requestJson(getDashboardStatsContract, {
|
||||
query: {
|
||||
workspaceId,
|
||||
...Object.fromEntries(params.entries()),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseDashboardStatsOptions {
|
||||
enabled?: boolean
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
|
||||
export function useDashboardStats(
|
||||
workspaceId: string | undefined,
|
||||
filters: Omit<LogFilters, 'limit' | 'sortBy' | 'sortOrder'>,
|
||||
options?: UseDashboardStatsOptions
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.stat(workspaceId, filters),
|
||||
queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: LOG_DASHBOARD_STATS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export type { ExecutionSnapshotData }
|
||||
|
||||
async function fetchExecutionSnapshot(
|
||||
executionId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExecutionSnapshotData> {
|
||||
const data = await requestJson(getExecutionSnapshotContract, {
|
||||
params: { executionId },
|
||||
signal,
|
||||
})
|
||||
if (!data) {
|
||||
throw new Error('No execution snapshot data returned')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function useExecutionSnapshot(executionId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.executionSnapshot(executionId),
|
||||
queryFn: ({ signal }) => fetchExecutionSnapshot(executionId as string, signal),
|
||||
enabled: Boolean(executionId),
|
||||
staleTime: EXECUTION_SNAPSHOT_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelExecution(workspaceId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
executionId,
|
||||
}: {
|
||||
workflowId: string
|
||||
executionId: string
|
||||
}) => {
|
||||
const data = await requestJson(cancelWorkflowExecutionContract, {
|
||||
params: { id: workflowId, executionId },
|
||||
})
|
||||
if (!data.success) throw new Error('Failed to cancel run')
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ executionId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: logKeys.lists() })
|
||||
|
||||
const previousQueries = queryClient.getQueriesData<InfiniteData<LogsPage>>({
|
||||
queryKey: logKeys.lists(),
|
||||
})
|
||||
|
||||
let affectedLogId: string | null = null
|
||||
queryClient.setQueriesData<InfiniteData<LogsPage>>({ queryKey: logKeys.lists() }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
logs: page.logs.map((log) => {
|
||||
if (log.executionId !== executionId) return log
|
||||
affectedLogId = log.id
|
||||
return { ...log, status: 'cancelling' }
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
let previousDetail: WorkflowLogDetail | undefined
|
||||
if (affectedLogId) {
|
||||
previousDetail = queryClient.getQueryData<WorkflowLogDetail>(
|
||||
logKeys.detail(workspaceId, affectedLogId)
|
||||
)
|
||||
if (previousDetail) {
|
||||
queryClient.setQueryData<WorkflowLogDetail>(logKeys.detail(workspaceId, affectedLogId), {
|
||||
...previousDetail,
|
||||
status: 'cancelling',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { previousQueries, affectedLogId, previousDetail }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
for (const [queryKey, data] of context?.previousQueries ?? []) {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
}
|
||||
if (context?.affectedLogId && context.previousDetail !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
logKeys.detail(workspaceId, context.affectedLogId),
|
||||
context.previousDetail
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.stats() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRetryExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => {
|
||||
// boundary-raw-fetch: stream response, body is a ReadableStream consumed one chunk at a time
|
||||
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, triggerType: 'manual', stream: true }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to retry execution')
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
await reader.read()
|
||||
reader.cancel()
|
||||
}
|
||||
return { started: true }
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() })
|
||||
queryClient.invalidateQueries({ queryKey: logKeys.stats() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createMcpServerContract,
|
||||
deleteMcpServerContract,
|
||||
discoverMcpToolsContract,
|
||||
getAllowedMcpDomainsContract,
|
||||
listMcpServersContract,
|
||||
listStoredMcpToolsContract,
|
||||
type McpServer,
|
||||
type McpServerTestBody,
|
||||
type McpServerTestResult,
|
||||
type RefreshMcpServerResult,
|
||||
refreshMcpServerContract,
|
||||
startMcpOauthContract,
|
||||
testMcpServerConnectionContract,
|
||||
updateMcpServerContract,
|
||||
} from '@/lib/api/contracts/mcp'
|
||||
import { isLoopbackHostname } from '@/lib/core/utils/urls'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
|
||||
import type {
|
||||
McpAuthType,
|
||||
McpServerStatusConfig,
|
||||
McpTool,
|
||||
McpTransport,
|
||||
StoredMcpTool,
|
||||
} from '@/lib/mcp/types'
|
||||
import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
export type { McpServerStatusConfig, McpTool, StoredMcpTool }
|
||||
|
||||
export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000
|
||||
export const MCP_SERVER_TOOLS_STALE_TIME = 30 * 1000
|
||||
export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000
|
||||
export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: () => [...mcpKeys.all, 'servers'] as const,
|
||||
serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const,
|
||||
serverTools: () => [...mcpKeys.all, 'serverTools'] as const,
|
||||
serverToolsWorkspace: (workspaceId?: string) =>
|
||||
[...mcpKeys.serverTools(), workspaceId ?? ''] as const,
|
||||
serverToolsList: (workspaceId?: string, serverId?: string) =>
|
||||
[...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const,
|
||||
storedTools: () => [...mcpKeys.all, 'storedTools'] as const,
|
||||
storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const,
|
||||
allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const,
|
||||
}
|
||||
|
||||
export type { McpServer }
|
||||
|
||||
/** Wire shape for create/update; distinct from runtime McpServerConfig. */
|
||||
export interface McpServerInput {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
timeout: number
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
oauthClientId?: string
|
||||
oauthClientSecret?: string
|
||||
authType?: McpAuthType
|
||||
}
|
||||
|
||||
async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise<McpServer[]> {
|
||||
try {
|
||||
const data = await requestJson(listMcpServersContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.servers
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function useMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.serversList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: MCP_SERVER_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchMcpTools(
|
||||
workspaceId: string,
|
||||
forceRefresh = false,
|
||||
signal?: AbortSignal,
|
||||
serverId?: string
|
||||
): Promise<McpTool[]> {
|
||||
try {
|
||||
const data = await requestJson(discoverMcpToolsContract, {
|
||||
query: {
|
||||
workspaceId,
|
||||
refresh: forceRefresh || undefined,
|
||||
...(serverId ? { serverId } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return data.data.tools
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace aggregate derived from N parallel per-server queries via
|
||||
* `useQueries`. One slow server cannot block the others.
|
||||
*/
|
||||
export function useMcpToolsQuery(workspaceId: string) {
|
||||
const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId)
|
||||
|
||||
// Skip disabled rows (would 404 → negative-cache) and rows from a previous
|
||||
// workspace (keepPreviousData on useMcpServers).
|
||||
const serverIds = useMemo(
|
||||
() =>
|
||||
servers
|
||||
? servers
|
||||
.filter((s) => s.enabled && s.workspaceId === workspaceId)
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
: [],
|
||||
[servers, workspaceId]
|
||||
)
|
||||
|
||||
const results = useQueries({
|
||||
queries: serverIds.map((serverId) => ({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, serverId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchMcpTools(workspaceId, false, signal, serverId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: MCP_SERVER_TOOLS_STALE_TIME,
|
||||
refetchOnWindowFocus: false,
|
||||
})),
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const tools: McpTool[] = []
|
||||
let hasData = false
|
||||
let anyServerLoading = false
|
||||
let firstError: Error | null = null
|
||||
const toolsStateByServer = new Map<
|
||||
string,
|
||||
{ isLoading: boolean; isFetching: boolean; error: Error | null }
|
||||
>()
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
const result = results[index]
|
||||
// Drop stale data from servers whose latest refetch errored.
|
||||
if (result.data && !result.isError) {
|
||||
tools.push(...result.data)
|
||||
hasData = true
|
||||
}
|
||||
if (result.isLoading) anyServerLoading = true
|
||||
if (!firstError && result.error instanceof Error) firstError = result.error
|
||||
|
||||
const serverId = serverIds[index]
|
||||
if (serverId) {
|
||||
toolsStateByServer.set(serverId, {
|
||||
isLoading: result.isLoading,
|
||||
isFetching: result.isFetching,
|
||||
error: result.error instanceof Error ? result.error : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: tools,
|
||||
isLoading: (serversLoading || anyServerLoading) && !hasData,
|
||||
isFetching: serversLoading || results.some((r) => r.isFetching),
|
||||
// Suppress when any healthy server rendered; per-server errors live in `toolsStateByServer`.
|
||||
error: hasData ? null : firstError,
|
||||
toolsStateByServer,
|
||||
}
|
||||
}, [results, serversLoading, serverIds])
|
||||
}
|
||||
|
||||
export function useForceRefreshMcpTools() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (workspaceId: string) => {
|
||||
const allServers =
|
||||
queryClient.getQueryData<McpServer[]>(mcpKeys.serversList(workspaceId)) ?? []
|
||||
const servers = allServers.filter((s) => s.enabled && s.workspaceId === workspaceId)
|
||||
const results = await Promise.allSettled(
|
||||
servers.map(async (server) => {
|
||||
const tools = await fetchMcpTools(workspaceId, true, undefined, server.id)
|
||||
queryClient.setQueryData(mcpKeys.serverToolsList(workspaceId, server.id), tools)
|
||||
return tools
|
||||
})
|
||||
)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const failedServer = servers[index]
|
||||
if (failedServer) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, failedServer.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<McpTool[]> => r.status === 'fulfilled')
|
||||
.flatMap((r) => r.value)
|
||||
},
|
||||
onSettled: (_data, _error, workspaceId) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateMcpServerParams {
|
||||
workspaceId: string
|
||||
config: McpServerInput
|
||||
}
|
||||
|
||||
export function useCreateMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, config }: CreateMcpServerParams) => {
|
||||
const serverData = {
|
||||
...config,
|
||||
url: config.url ? sanitizeForHttp(config.url) : config.url,
|
||||
headers: sanitizeHeaders(config.headers),
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const data = await requestJson(createMcpServerContract, {
|
||||
body: serverData,
|
||||
})
|
||||
|
||||
const serverId = data.data.serverId
|
||||
const wasUpdated = data.data.updated === true
|
||||
const authType = data.data.authType
|
||||
|
||||
logger.info(
|
||||
wasUpdated
|
||||
? `Updated existing MCP server: ${config.name} (ID: ${serverId})`
|
||||
: `Created MCP server: ${config.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
const { oauthClientSecret: _omitSecret, ...safeServerData } = serverData
|
||||
return {
|
||||
...safeServerData,
|
||||
id: serverId,
|
||||
connectionStatus: authType === 'oauth' ? ('disconnected' as const) : ('connected' as const),
|
||||
serverId,
|
||||
updated: wasUpdated,
|
||||
authType,
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsWorkspace(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** On `redirect`, the caller must wait for `popup.closed` or the `mcp-oauth` postMessage. */
|
||||
export type StartMcpOauthMutationResult =
|
||||
| { status: 'redirect'; popup: Window }
|
||||
| { status: 'already_authorized' }
|
||||
|
||||
export function useStartMcpOauth() {
|
||||
return useMutation<StartMcpOauthMutationResult, Error, { serverId: string; workspaceId: string }>(
|
||||
{
|
||||
mutationFn: async ({ serverId, workspaceId }) => {
|
||||
const result = await requestJson(startMcpOauthContract, {
|
||||
query: { serverId, workspaceId },
|
||||
})
|
||||
if (result.status === 'already_authorized') return { status: 'already_authorized' }
|
||||
|
||||
const parsedUrl = new URL(result.authorizationUrl)
|
||||
const isLoopbackHttp =
|
||||
parsedUrl.protocol === 'http:' && isLoopbackHostname(parsedUrl.hostname)
|
||||
if (parsedUrl.protocol !== 'https:' && !isLoopbackHttp) {
|
||||
throw new Error('Authorization URL must use HTTPS')
|
||||
}
|
||||
const popup = window.open(
|
||||
result.authorizationUrl,
|
||||
`mcp-oauth-${serverId}`,
|
||||
'width=560,height=720,resizable=yes,scrollbars=yes'
|
||||
)
|
||||
if (!popup) {
|
||||
throw new Error('Popup blocked. Please allow popups for this site and retry.')
|
||||
}
|
||||
return { status: 'redirect', popup }
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface DeleteMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: DeleteMcpServerParams) => {
|
||||
const data = await requestJson(deleteMcpServerContract, {
|
||||
query: { serverId, workspaceId },
|
||||
})
|
||||
|
||||
logger.info(`Deleted MCP server: ${serverId} from workspace: ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.removeQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
updates: Partial<McpServerInput>
|
||||
}
|
||||
|
||||
export function useUpdateMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
|
||||
const sanitizedUpdates = {
|
||||
...updates,
|
||||
url: updates.url ? sanitizeForHttp(updates.url) : updates.url,
|
||||
headers: updates.headers ? sanitizeHeaders(updates.headers) : updates.headers,
|
||||
}
|
||||
|
||||
const data = await requestJson(updateMcpServerContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
body: sanitizedUpdates,
|
||||
})
|
||||
|
||||
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
|
||||
return data.data.server
|
||||
},
|
||||
onMutate: async ({ workspaceId, serverId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(
|
||||
mcpKeys.serversList(workspaceId)
|
||||
)
|
||||
|
||||
if (previousServers) {
|
||||
const { oauthClientSecret: _omitSecret, oauthClientId, ...rest } = updates
|
||||
const safeUpdates: Partial<McpServer> = { ...rest }
|
||||
if (oauthClientId !== undefined) {
|
||||
safeUpdates.oauthClientId = oauthClientId || undefined
|
||||
}
|
||||
queryClient.setQueryData<McpServer[]>(
|
||||
mcpKeys.serversList(workspaceId),
|
||||
previousServers.map((server) =>
|
||||
server.id === serverId
|
||||
? { ...server, ...safeUpdates, updatedAt: new Date().toISOString() }
|
||||
: server
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousServers }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousServers) {
|
||||
queryClient.setQueryData(
|
||||
mcpKeys.serversList(variables.workspaceId),
|
||||
context.previousServers
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface RefreshMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export type { RefreshMcpServerResult }
|
||||
|
||||
export function useRefreshMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: RefreshMcpServerParams): Promise<RefreshMcpServerResult> => {
|
||||
const data = await requestJson(refreshMcpServerContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchStoredMcpTools(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StoredMcpTool[]> {
|
||||
const data = await requestJson(listStoredMcpToolsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.tools
|
||||
}
|
||||
|
||||
export function useStoredMcpTools(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.storedToolsList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchStoredMcpTools(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: MCP_STORED_TOOL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared EventSource connections keyed by workspaceId.
|
||||
* Reference-counted so the connection is closed when the last consumer unmounts.
|
||||
* Attached to `globalThis` so connections survive HMR in development.
|
||||
*/
|
||||
const SSE_KEY = '__mcp_sse_connections' as const
|
||||
|
||||
type SseEntry = { source: EventSource; refs: number }
|
||||
|
||||
const sseConnections: Map<string, SseEntry> =
|
||||
((globalThis as Record<string, unknown>)[SSE_KEY] as Map<string, SseEntry>) ??
|
||||
((globalThis as Record<string, unknown>)[SSE_KEY] = new Map<string, SseEntry>())
|
||||
|
||||
/** Subscribes to `tools_changed` SSE events and invalidates the affected query keys. */
|
||||
export function useMcpToolsEvents(workspaceId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
|
||||
const invalidate = (serverId?: string) => {
|
||||
if (serverId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.serverToolsList(workspaceId, serverId),
|
||||
})
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsWorkspace(workspaceId) })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all })
|
||||
}
|
||||
|
||||
let entry = sseConnections.get(workspaceId)
|
||||
|
||||
if (!entry) {
|
||||
const source = new EventSource(`/api/mcp/events?workspaceId=${workspaceId}`)
|
||||
|
||||
source.addEventListener('tools_changed', (e) => {
|
||||
let serverId: string | undefined
|
||||
try {
|
||||
const parsed = JSON.parse((e as MessageEvent).data) as { serverId?: string }
|
||||
serverId = parsed.serverId
|
||||
} catch {
|
||||
// Non-JSON payload → workspace-wide fallback.
|
||||
}
|
||||
invalidate(serverId)
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
logger.warn(`SSE connection error for workspace ${workspaceId}`)
|
||||
}
|
||||
|
||||
entry = { source, refs: 0 }
|
||||
sseConnections.set(workspaceId, entry)
|
||||
}
|
||||
|
||||
entry.refs++
|
||||
|
||||
return () => {
|
||||
const current = sseConnections.get(workspaceId)
|
||||
if (!current) return
|
||||
|
||||
current.refs--
|
||||
if (current.refs <= 0) {
|
||||
current.source.close()
|
||||
sseConnections.delete(workspaceId)
|
||||
}
|
||||
}
|
||||
}, [workspaceId, queryClient])
|
||||
}
|
||||
|
||||
export type McpServerTestConfig = McpServerTestBody & {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export type { McpServerTestResult }
|
||||
|
||||
function isMcpTestErrorBody(body: unknown): body is { data?: McpServerTestResult } {
|
||||
return Boolean(body) && typeof body === 'object' && 'data' in (body as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function testMcpServerConnection(
|
||||
config: McpServerTestConfig,
|
||||
signal?: AbortSignal
|
||||
): Promise<McpServerTestResult> {
|
||||
const cleanConfig = {
|
||||
...config,
|
||||
url: config.url ? sanitizeForHttp(config.url) : config.url,
|
||||
headers: sanitizeHeaders(config.headers) || {},
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await requestJson(testMcpServerConnectionContract, {
|
||||
body: cleanConfig,
|
||||
signal,
|
||||
})
|
||||
return data.data
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && isMcpTestErrorBody(error.body) && error.body.data) {
|
||||
const inner = error.body.data
|
||||
if (inner.error || inner.success === false) {
|
||||
return {
|
||||
success: false,
|
||||
message: inner.error || 'Connection failed',
|
||||
error: inner.error,
|
||||
warnings: inner.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function useMcpServerTest() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (config: McpServerTestConfig) => testMcpServerConnection(config),
|
||||
onSuccess: (result, variables) => {
|
||||
logger.info(`MCP server test ${result.success ? 'passed' : 'failed'}:`, variables.name)
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('MCP server test failed:', getErrorMessage(error))
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
testResult:
|
||||
mutation.data ??
|
||||
(mutation.error
|
||||
? ({
|
||||
success: false,
|
||||
message: 'Connection failed',
|
||||
error: getErrorMessage(mutation.error, 'Unknown error occurred'),
|
||||
} as McpServerTestResult)
|
||||
: null),
|
||||
isTestingConnection: mutation.isPending,
|
||||
testConnection: mutation.mutateAsync,
|
||||
clearTestResult: mutation.reset,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllowedMcpDomains(signal?: AbortSignal): Promise<string[] | null> {
|
||||
const data = await requestJson(getAllowedMcpDomainsContract, { signal })
|
||||
return data.allowedMcpDomains ?? null
|
||||
}
|
||||
|
||||
export function useAllowedMcpDomains() {
|
||||
return useQuery<string[] | null>({
|
||||
queryKey: mcpKeys.allowedDomains(),
|
||||
queryFn: ({ signal }) => fetchAllowedMcpDomains(signal),
|
||||
staleTime: MCP_ALLOWED_DOMAINS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
export type MothershipEnv = 'default' | 'dev' | 'staging' | 'prod'
|
||||
|
||||
const BASE = '/api/admin/mothership'
|
||||
|
||||
/**
|
||||
* Same-origin proxy to the mothership admin API. Both the request body and
|
||||
* the response shape vary per upstream `endpoint` query parameter, so a
|
||||
* single contract cannot capture the union; the proxy returns the upstream
|
||||
* JSON verbatim. `requestJson` would force a fixed response schema, so this
|
||||
* hook stays on raw `fetch` and surfaces upstream errors through `adminError`.
|
||||
*/
|
||||
async function mothershipPost(
|
||||
endpoint: string,
|
||||
environment: MothershipEnv,
|
||||
body?: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const qs = new URLSearchParams({ env: environment, endpoint })
|
||||
// boundary-raw-fetch: same-origin proxy whose response shape varies per upstream endpoint
|
||||
const res = await fetch(`${BASE}?${qs.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.message || err.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin proxy GET for the mothership admin API. See `mothershipPost`
|
||||
* for the rationale on staying with raw `fetch`.
|
||||
*/
|
||||
async function mothershipGet(
|
||||
endpoint: string,
|
||||
environment: MothershipEnv,
|
||||
params?: Record<string, string>,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const qs = new URLSearchParams({ env: environment, endpoint, ...params })
|
||||
// boundary-raw-fetch: same-origin proxy whose response shape varies per upstream endpoint
|
||||
const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET', signal })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.message || err.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Enterprise BYOK does NOT use the cross-env admin proxy. It talks to the
|
||||
// workspace's own copilot (SIM_AGENT_API_URL — local in dev, prod copilot in
|
||||
// prod) via a dedicated same-origin route that authenticates with the hosted
|
||||
// internal key. So it always targets the copilot the mothership actually runs
|
||||
// on, never a deployed dev/staging URL.
|
||||
const BYOK_BASE = '/api/copilot/byok'
|
||||
|
||||
async function byokFetch(url: string, init?: RequestInit) {
|
||||
// boundary-raw-fetch: thin same-origin proxy to copilot; response shape is the
|
||||
// upstream copilot JSON.
|
||||
const res = await fetch(url, init)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.message || err.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const MOTHERSHIP_BYOK_STALE_TIME = 30 * 1000
|
||||
export const MOTHERSHIP_REQUESTS_STALE_TIME = 60 * 1000
|
||||
export const MOTHERSHIP_USER_BREAKDOWN_STALE_TIME = 60 * 1000
|
||||
export const MOTHERSHIP_LICENSE_LIST_STALE_TIME = 60 * 1000
|
||||
export const MOTHERSHIP_LICENSE_DETAIL_STALE_TIME = 60 * 1000
|
||||
|
||||
export const mothershipKeys = {
|
||||
all: ['mothership-admin'] as const,
|
||||
requests: (env: MothershipEnv, start: string, end: string, userId?: string) =>
|
||||
[...mothershipKeys.all, 'requests', env, start, end, userId] as const,
|
||||
userBreakdown: (env: MothershipEnv, start: string, end: string) =>
|
||||
[...mothershipKeys.all, 'user-breakdown', env, start, end] as const,
|
||||
licenses: (env: MothershipEnv) => [...mothershipKeys.all, 'licenses', env] as const,
|
||||
licenseDetails: (env: MothershipEnv, id?: string, name?: string) =>
|
||||
[...mothershipKeys.all, 'license-details', env, id, name] as const,
|
||||
byok: (workspaceId: string) => [...mothershipKeys.all, 'byok', workspaceId] as const,
|
||||
}
|
||||
|
||||
export interface MothershipByokKey {
|
||||
provider: string
|
||||
keyLastFour: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** List the enterprise BYOK keys stored for a workspace (metadata only). */
|
||||
export function useMothershipByokKeys(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.byok(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
byokFetch(`${BYOK_BASE}?workspaceId=${encodeURIComponent(workspaceId)}`, { signal }),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: MOTHERSHIP_BYOK_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/** Store (or replace) a workspace's enterprise BYOK key for a provider. */
|
||||
export function useUpsertMothershipByok() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (params: { workspaceId: string; provider: string; apiKey: string }) =>
|
||||
byokFetch(BYOK_BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
onSettled: (_data, _error, params) =>
|
||||
queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Delete a workspace's enterprise BYOK key for a provider. */
|
||||
export function useDeleteMothershipByok() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (params: { workspaceId: string; provider: string }) =>
|
||||
byokFetch(
|
||||
`${BYOK_BASE}?${new URLSearchParams({
|
||||
workspaceId: params.workspaceId,
|
||||
provider: params.provider,
|
||||
}).toString()}`,
|
||||
{ method: 'DELETE' }
|
||||
),
|
||||
onSettled: (_data, _error, params) =>
|
||||
queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipRequests(
|
||||
environment: MothershipEnv,
|
||||
start: string,
|
||||
end: string,
|
||||
userId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.requests(environment, start, end, userId),
|
||||
queryFn: ({ signal }) =>
|
||||
mothershipPost(
|
||||
'requests',
|
||||
environment,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
...(userId ? { userId } : {}),
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: !!start && !!end,
|
||||
staleTime: MOTHERSHIP_REQUESTS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipUserBreakdown(environment: MothershipEnv, start: string, end: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.userBreakdown(environment, start, end),
|
||||
queryFn: ({ signal }) => mothershipPost('user-breakdown', environment, { start, end }, signal),
|
||||
enabled: !!start && !!end,
|
||||
staleTime: MOTHERSHIP_USER_BREAKDOWN_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipLicenses(environment: MothershipEnv) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.licenses(environment),
|
||||
queryFn: ({ signal }) => mothershipGet('licenses', environment, undefined, signal),
|
||||
staleTime: MOTHERSHIP_LICENSE_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipLicenseDetails(
|
||||
environment: MothershipEnv,
|
||||
id?: string,
|
||||
name?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.licenseDetails(environment, id, name),
|
||||
queryFn: ({ signal }) =>
|
||||
mothershipPost(
|
||||
'licenses/details',
|
||||
environment,
|
||||
{
|
||||
...(id ? { id } : {}),
|
||||
...(name ? { name } : {}),
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: !!(id || name),
|
||||
staleTime: MOTHERSHIP_LICENSE_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateLicense(environment: MothershipEnv) {
|
||||
return useMutation({
|
||||
mutationFn: (params: { name: string; expirationDate?: string }) =>
|
||||
mothershipPost('licenses/generate', environment, params),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MothershipResource } from '@/lib/copilot/resources/types'
|
||||
|
||||
const { queryClient } = vi.hoisted(() => ({
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryData: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: {},
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(() => queryClient),
|
||||
useMutation: vi.fn((options) => options),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchMothershipChatHistory,
|
||||
fetchMothershipChats,
|
||||
useAddChatResource,
|
||||
} from '@/hooks/queries/mothership-chats'
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...init,
|
||||
})
|
||||
}
|
||||
|
||||
describe('tasks query boundary parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('parses valid task metadata responses', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Launch plan',
|
||||
updatedAt: '2026-04-11T10:00:00.000Z',
|
||||
activeStreamId: 'stream-1',
|
||||
lastSeenAt: null,
|
||||
pinned: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const tasks = await fetchMothershipChats('ws-1')
|
||||
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'chat-1',
|
||||
name: 'Launch plan',
|
||||
isActive: true,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
})
|
||||
)
|
||||
expect(tasks[0]?.updatedAt.toISOString()).toBe('2026-04-11T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('rejects invalid task metadata responses', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 123,
|
||||
title: 'Broken',
|
||||
updatedAt: '2026-04-11T10:00:00.000Z',
|
||||
activeStreamId: null,
|
||||
lastSeenAt: null,
|
||||
pinned: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchMothershipChats('ws-1')).rejects.toThrow(
|
||||
'Response failed contract validation'
|
||||
)
|
||||
})
|
||||
|
||||
it('parses valid mothership chat history responses', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
chat: {
|
||||
id: 'chat-1',
|
||||
title: 'Task history',
|
||||
messages: [],
|
||||
activeStreamId: 'stream-1',
|
||||
resources: [{ type: 'file', id: 'file-1', title: 'Spec.md' }],
|
||||
streamSnapshot: {
|
||||
events: [],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const history = await fetchMothershipChatHistory('chat-1')
|
||||
|
||||
expect(history).toEqual({
|
||||
id: 'chat-1',
|
||||
title: 'Task history',
|
||||
messages: [],
|
||||
activeStreamId: 'stream-1',
|
||||
resources: [{ type: 'file', id: 'file-1', title: 'Spec.md' }],
|
||||
streamSnapshot: {
|
||||
events: [],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid fallback chat history responses', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('Not found', { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
chat: {
|
||||
id: 'chat-1',
|
||||
title: null,
|
||||
messages: [],
|
||||
activeStreamId: null,
|
||||
resources: [{ type: 'bogus', id: 'resource-1', title: 'Broken' }],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchMothershipChatHistory('chat-1')).rejects.toThrow(
|
||||
'Invalid chat response: chat.resources[0].type is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid chat resource mutation responses', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
})
|
||||
)
|
||||
|
||||
const mutation = useAddChatResource('chat-1') as unknown as {
|
||||
mutationFn: (input: {
|
||||
chatId: string
|
||||
resource: MothershipResource
|
||||
}) => Promise<{ resources: MothershipResource[] }>
|
||||
}
|
||||
|
||||
await expect(
|
||||
mutation.mutationFn({
|
||||
chatId: 'chat-1',
|
||||
resource: { type: 'file', id: 'file-1', title: 'Spec.md' },
|
||||
})
|
||||
).rejects.toThrow('Response failed contract validation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,721 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import {
|
||||
keepPreviousData,
|
||||
skipToken,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
addMothershipChatResourceContract,
|
||||
createMothershipChatContract,
|
||||
deleteMothershipChatContract,
|
||||
forkMothershipChatContract,
|
||||
getMothershipChatContract,
|
||||
listMothershipChatsContract,
|
||||
type MothershipChat,
|
||||
removeMothershipChatResourceContract,
|
||||
reorderMothershipChatResourcesContract,
|
||||
updateMothershipChatContract,
|
||||
} from '@/lib/api/contracts/mothership-chats'
|
||||
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
type FilePreviewSession,
|
||||
isFilePreviewSession,
|
||||
} from '@/lib/copilot/request/session/file-preview-session-contract'
|
||||
import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types'
|
||||
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
|
||||
|
||||
export interface MothershipChatMetadata {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: Date
|
||||
isActive: boolean
|
||||
isUnread: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
|
||||
export interface MothershipChatHistory {
|
||||
id: string
|
||||
title: string | null
|
||||
messages: PersistedMessage[]
|
||||
activeStreamId: string | null
|
||||
resources: MothershipResource[]
|
||||
streamSnapshot?: {
|
||||
events: StreamBatchEvent[]
|
||||
previewSessions: FilePreviewSession[]
|
||||
status: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export const mothershipChatKeys = {
|
||||
all: ['mothership-chats'] as const,
|
||||
lists: () => [...mothershipChatKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined) =>
|
||||
[...mothershipChatKeys.lists(), workspaceId ?? ''] as const,
|
||||
details: () => [...mothershipChatKeys.all, 'detail'] as const,
|
||||
detail: (chatId: string | undefined) => [...mothershipChatKeys.details(), chatId ?? ''] as const,
|
||||
}
|
||||
|
||||
/** Shared by the `useMothershipChats` hook and the workspace sidebar prefetch. */
|
||||
export const MOTHERSHIP_CHAT_LIST_STALE_TIME = 60 * 1000
|
||||
export const MOTHERSHIP_CHAT_HISTORY_STALE_TIME = 30 * 1000
|
||||
|
||||
function assertValid(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isResourceType(value: unknown): value is MothershipResource['type'] {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(MothershipResourceType).some((type) => type === value)
|
||||
)
|
||||
}
|
||||
|
||||
function parseStreamSnapshot(value: unknown): MothershipChatHistory['streamSnapshot'] {
|
||||
if (!isRecordLike(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawEvents = Array.isArray(value.events) ? value.events : []
|
||||
const events: StreamBatchEvent[] = []
|
||||
for (const entry of rawEvents) {
|
||||
if (!isStreamBatchEvent(entry)) {
|
||||
return null
|
||||
}
|
||||
events.push(entry)
|
||||
}
|
||||
|
||||
const rawPreviewSessions = Array.isArray(value.previewSessions) ? value.previewSessions : []
|
||||
const previewSessions: FilePreviewSession[] = []
|
||||
for (const session of rawPreviewSessions) {
|
||||
if (!isFilePreviewSession(session)) {
|
||||
return null
|
||||
}
|
||||
previewSessions.push(session)
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
previewSessions,
|
||||
status: typeof value.status === 'string' ? value.status : 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessages(value: unknown): PersistedMessage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.filter(isRecordLike).map((message) => normalizeMessage(message))
|
||||
}
|
||||
|
||||
function parseResource(value: unknown, context: string): MothershipResource {
|
||||
assertValid(isRecordLike(value), `${context} must be an object`)
|
||||
assertValid(isResourceType(value.type), `${context}.type is invalid`)
|
||||
assertValid(typeof value.id === 'string', `${context}.id must be a string`)
|
||||
assertValid(typeof value.title === 'string', `${context}.title must be a string`)
|
||||
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
}
|
||||
}
|
||||
|
||||
function parseResources(value: unknown, context: string): MothershipResource[] {
|
||||
assertValid(Array.isArray(value), `${context} must be an array`)
|
||||
|
||||
return value.map((resource, index) => parseResource(resource, `${context}[${index}]`))
|
||||
}
|
||||
|
||||
function parseStrictStreamSnapshot(
|
||||
value: unknown,
|
||||
context: string
|
||||
): MothershipChatHistory['streamSnapshot'] {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = parseStreamSnapshot(value)
|
||||
assertValid(snapshot !== null, `${context} is invalid`)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function parseChatHistory(value: unknown): MothershipChatHistory {
|
||||
const responseContext = 'Invalid chat response'
|
||||
const chatContext = `${responseContext}: chat`
|
||||
|
||||
assertValid(isRecordLike(value), `${responseContext}: body must be an object`)
|
||||
assertValid(isRecordLike(value.chat), `${chatContext} must be an object`)
|
||||
|
||||
const chat = value.chat
|
||||
|
||||
assertValid(typeof chat.id === 'string', `${chatContext}.id must be a string`)
|
||||
assertValid(isNullableString(chat.title), `${chatContext}.title must be a string or null`)
|
||||
assertValid(Array.isArray(chat.messages), `${chatContext}.messages must be an array`)
|
||||
assertValid(
|
||||
isNullableString(chat.activeStreamId),
|
||||
`${chatContext}.activeStreamId must be a string or null`
|
||||
)
|
||||
|
||||
return {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: normalizeMessages(chat.messages),
|
||||
activeStreamId: chat.activeStreamId,
|
||||
resources: parseResources(chat.resources, `${chatContext}.resources`),
|
||||
streamSnapshot: parseStrictStreamSnapshot(chat.streamSnapshot, `${chatContext}.streamSnapshot`),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatResourcesResponse(value: unknown): { resources: MothershipResource[] } {
|
||||
assertValid(isRecordLike(value), 'Invalid chat resources response: body must be an object')
|
||||
|
||||
return {
|
||||
resources: parseResources(value.resources, 'Invalid chat resources response: resources'),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapChat(chat: MothershipChat): MothershipChatMetadata {
|
||||
const updatedAt = new Date(chat.updatedAt)
|
||||
return {
|
||||
id: chat.id,
|
||||
name: chat.title ?? 'New chat',
|
||||
updatedAt,
|
||||
isActive: chat.activeStreamId !== null,
|
||||
isUnread:
|
||||
chat.activeStreamId === null &&
|
||||
(chat.lastSeenAt === null || updatedAt > new Date(chat.lastSeenAt)),
|
||||
isPinned: chat.pinned,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMothershipChats(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MothershipChatMetadata[]> {
|
||||
const data = await requestJson(listMothershipChatsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.map(mapChat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches mothership chat chats for a workspace.
|
||||
* These are workspace-scoped conversations from the Home page.
|
||||
*/
|
||||
export function useMothershipChats(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipChatKeys.list(workspaceId),
|
||||
queryFn: workspaceId ? ({ signal }) => fetchMothershipChats(workspaceId, signal) : skipToken,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchMothershipChatHistory(
|
||||
chatId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MothershipChatHistory> {
|
||||
try {
|
||||
const data = await requestJson(getMothershipChatContract, {
|
||||
params: { chatId },
|
||||
signal,
|
||||
})
|
||||
return parseChatHistory(data)
|
||||
} catch (error) {
|
||||
if (!isApiClientError(error)) throw error
|
||||
// Fall through to the legacy copilot-shape alias on any HTTP error (typically 404
|
||||
// when the chat lives in the older copilot table and isn't a mothership-typed row).
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the
|
||||
// copilot lifecycle shape (activeStreamId, not conversationId) for chats stored under
|
||||
// the older copilot table; no contract exists for this alias path
|
||||
const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, {
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!copilotRes.ok) {
|
||||
throw new Error('Failed to load chat')
|
||||
}
|
||||
|
||||
return parseChatHistory(await copilotRes.json())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches chat history for a single chat (mothership chat).
|
||||
* Used by the chat page to load an existing conversation.
|
||||
*/
|
||||
export function useMothershipChatHistory(chatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: mothershipChatKeys.detail(chatId),
|
||||
queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken,
|
||||
staleTime: MOTHERSHIP_CHAT_HISTORY_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteChat(chatId: string): Promise<void> {
|
||||
await requestJson(deleteMothershipChatContract, {
|
||||
params: { chatId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a mothership chat chat and invalidates the chat list.
|
||||
*/
|
||||
export function useDeleteMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: deleteChat,
|
||||
onSettled: (_data, _error, chatId) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
useMothershipQueueStore.getState().clearChat(chatId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple mothership chat chats and invalidates the chat list.
|
||||
*/
|
||||
export function useDeleteMothershipChats(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (chatIds: string[]) => {
|
||||
await Promise.all(chatIds.map(deleteChat))
|
||||
},
|
||||
onSettled: (_data, _error, chatIds) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const queueStore = useMothershipQueueStore.getState()
|
||||
for (const chatId of chatIds) {
|
||||
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
queueStore.clearChat(chatId)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function renameChat({ chatId, title }: { chatId: string; title: string }): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { title },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a mothership chat chat with optimistic update.
|
||||
*/
|
||||
export function useRenameMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: renameChat,
|
||||
onMutate: async ({ chatId, title }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId),
|
||||
(old) => old?.map((chat) => (chat.id === chatId ? { ...chat, name: title } : chat))
|
||||
)
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(variables.chatId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function addChatResource(params: {
|
||||
chatId: string
|
||||
resource: MothershipResource
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(addMothershipChatResourceContract, {
|
||||
body: { chatId: params.chatId, resource: params.resource },
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useAddChatResource(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: addChatResource,
|
||||
onMutate: async ({ resource }) => {
|
||||
if (!chatId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
const previous = queryClient.getQueryData<MothershipChatHistory>(
|
||||
mothershipChatKeys.detail(chatId)
|
||||
)
|
||||
if (previous) {
|
||||
const exists = previous.resources.some(
|
||||
(r) => r.type === resource.type && r.id === resource.id
|
||||
)
|
||||
if (!exists) {
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
|
||||
...previous,
|
||||
resources: [...previous.resources, resource],
|
||||
})
|
||||
}
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous && chatId) {
|
||||
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function reorderChatResources(params: {
|
||||
chatId: string
|
||||
resources: MothershipResource[]
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(reorderMothershipChatResourcesContract, {
|
||||
body: { chatId: params.chatId, resources: params.resources },
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useReorderChatResources(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: reorderChatResources,
|
||||
onMutate: async ({ resources }) => {
|
||||
if (!chatId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
const previous = queryClient.getQueryData<MothershipChatHistory>(
|
||||
mothershipChatKeys.detail(chatId)
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
|
||||
...previous,
|
||||
resources,
|
||||
})
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous && chatId) {
|
||||
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function removeChatResource(params: {
|
||||
chatId: string
|
||||
resourceType: string
|
||||
resourceId: string
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(removeMothershipChatResourceContract, {
|
||||
body: params,
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useRemoveChatResource(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: removeChatResource,
|
||||
onMutate: async ({ resourceType, resourceId }) => {
|
||||
if (!chatId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
const removed: MothershipChatHistory['resources'] = []
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) => {
|
||||
if (!prev) return prev
|
||||
const next: MothershipChatHistory['resources'] = []
|
||||
for (const r of prev.resources) {
|
||||
if (r.type === resourceType && r.id === resourceId) removed.push(r)
|
||||
else next.push(r)
|
||||
}
|
||||
return removed.length > 0 ? { ...prev, resources: next } : prev
|
||||
})
|
||||
return { removed }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (!chatId || !context?.removed.length) return
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) =>
|
||||
prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function markChatRead(chatId: string): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { isUnread: false },
|
||||
})
|
||||
}
|
||||
|
||||
async function markChatUnread(chatId: string): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { isUnread: true },
|
||||
})
|
||||
}
|
||||
|
||||
function applyUnreadFlag(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workspaceId: string | undefined,
|
||||
chatId: string,
|
||||
isUnread: boolean
|
||||
): void {
|
||||
const current = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!current) return
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId),
|
||||
current.map((chat) => (chat.id === chatId ? { ...chat, isUnread } : chat))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a chat as read with optimistic update.
|
||||
*
|
||||
* The server only updates `lastSeenAt`, never `updatedAt`, so we deliberately
|
||||
* do not invalidate the list cache — that would trigger a refetch that can
|
||||
* reorder the sidebar if any unrelated server-side update landed in between.
|
||||
*
|
||||
* If there is no cached list yet (initial fetch still in flight, e.g. on
|
||||
* chat-page refresh), we skip cancellation entirely so the in-flight fetch
|
||||
* can resolve normally — otherwise it would be orphaned and never refetched.
|
||||
* `onSuccess` then reconciles whichever state the fetch produced.
|
||||
*/
|
||||
export function useMarkMothershipChatRead(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: markChatRead,
|
||||
onMutate: async (chatId) => {
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!previousChats) return { previousChats }
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, false)
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onSuccess: (_data, chatId) => {
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, false)
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a chat as unread with optimistic update.
|
||||
*
|
||||
* Same rationale as `useMarkMothershipChatRead` — no list invalidation, since the server
|
||||
* only flips `lastSeenAt` and the optimistic update fully reflects the change.
|
||||
*/
|
||||
export function useMarkMothershipChatUnread(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: markChatUnread,
|
||||
onMutate: async (chatId) => {
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!previousChats) return { previousChats }
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, true)
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onSuccess: (_data, chatId) => {
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, true)
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function setChatPinned({
|
||||
chatId,
|
||||
pinned,
|
||||
}: {
|
||||
chatId: string
|
||||
pinned: boolean
|
||||
}): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { pinned },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins or unpins a chat with optimistic update. Pinned chats are sorted to
|
||||
* the top of the list by the server; the optimistic reducer preserves that
|
||||
* ordering by partitioning pinned and unpinned chats while keeping each
|
||||
* partition in its existing order (server returns desc(updatedAt) within).
|
||||
*/
|
||||
export function useSetMothershipChatPinned(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: setChatPinned,
|
||||
onMutate: async ({ chatId, pinned }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!previousChats) return { previousChats: undefined }
|
||||
|
||||
const updated = previousChats.map((chat) =>
|
||||
chat.id === chatId ? { ...chat, isPinned: pinned } : chat
|
||||
)
|
||||
const pinnedChats = updated.filter((chat) => chat.isPinned)
|
||||
const unpinnedChats = updated.filter((chat) => !chat.isPinned)
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
|
||||
...pinnedChats,
|
||||
...unpinnedChats,
|
||||
])
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createChat(workspaceId: string): Promise<{ id: string }> {
|
||||
const { id } = await requestJson(createMothershipChatContract, { body: { workspaceId } })
|
||||
return { id }
|
||||
}
|
||||
|
||||
export function useCreateMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
if (!workspaceId) throw new Error('workspaceId is required')
|
||||
return createChat(workspaceId)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!workspaceId) return
|
||||
const existing =
|
||||
queryClient.getQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId)) ??
|
||||
[]
|
||||
const newChat: MothershipChatMetadata = {
|
||||
id: data.id,
|
||||
name: 'New chat',
|
||||
updatedAt: new Date(),
|
||||
isActive: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
}
|
||||
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
|
||||
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
|
||||
...existing.slice(0, insertAt),
|
||||
newChat,
|
||||
...existing.slice(insertAt),
|
||||
])
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function forkChat(params: {
|
||||
chatId: string
|
||||
upToMessageId: string
|
||||
}): Promise<{ id: string }> {
|
||||
const data = await requestJson(forkMothershipChatContract, {
|
||||
params: { chatId: params.chatId },
|
||||
body: { upToMessageId: params.upToMessageId },
|
||||
})
|
||||
return { id: data.id }
|
||||
}
|
||||
|
||||
export function useForkMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: forkChat,
|
||||
onSuccess: async (data, variables) => {
|
||||
if (!workspaceId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const existing = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (existing) {
|
||||
const sourceChat = existing.find((t) => t.id === variables.chatId)
|
||||
const baseName = (sourceChat?.name ?? 'New chat').replace(/^Fork \| /, '')
|
||||
const optimisticChat: MothershipChatMetadata = {
|
||||
id: data.id,
|
||||
name: `Fork | ${baseName}`,
|
||||
updatedAt: new Date(),
|
||||
isActive: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
}
|
||||
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
|
||||
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
|
||||
...existing.slice(0, insertAt),
|
||||
optimisticChat,
|
||||
...existing.slice(insertAt),
|
||||
])
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ConnectedAccount,
|
||||
disconnectOAuthContract,
|
||||
listConnectedAccountsContract,
|
||||
listOAuthConnectionsContract,
|
||||
type OAuthAccountSummary,
|
||||
type OAuthConnection,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsQuery')
|
||||
|
||||
export const OAUTH_CONNECTIONS_STALE_TIME = 30 * 1000
|
||||
export const OAUTH_CONNECTED_ACCOUNTS_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* 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: () => [...oauthConnectionsKeys.all, 'accounts'] as const,
|
||||
account: (provider: string) => [...oauthConnectionsKeys.accounts(), provider] as const,
|
||||
}
|
||||
|
||||
/** OAuth service with connection status and linked accounts. */
|
||||
export interface ServiceInfo extends OAuthServiceConfig {
|
||||
id: string
|
||||
isConnected: boolean
|
||||
lastConnected?: string
|
||||
accounts?: OAuthAccountSummary[]
|
||||
}
|
||||
|
||||
type OAuthConnectionResponse = OAuthConnection
|
||||
|
||||
function defineServices(): ServiceInfo[] {
|
||||
const servicesList: ServiceInfo[] = []
|
||||
|
||||
Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => {
|
||||
Object.entries(provider.services).forEach(([serviceKey, service]) => {
|
||||
servicesList.push({
|
||||
...service,
|
||||
id: serviceKey,
|
||||
isConnected: false,
|
||||
scopes: service.scopes || [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return servicesList
|
||||
}
|
||||
|
||||
async function fetchOAuthConnections(signal?: AbortSignal): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const serviceDefinitions = defineServices()
|
||||
|
||||
const data = await requestJson(listOAuthConnectionsContract, { signal })
|
||||
const connections = data.connections || []
|
||||
|
||||
const updatedServices = serviceDefinitions.map((service) => {
|
||||
const connection = connections.find(
|
||||
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
|
||||
)
|
||||
|
||||
if (connection) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: (connection.accounts?.length ?? 0) > 0,
|
||||
accounts: connection.accounts || [],
|
||||
lastConnected: connection.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
|
||||
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (conn.scopes && service.scopes) {
|
||||
const connScopes = conn.scopes
|
||||
return service.scopes.every((scope) => connScopes.includes(scope))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (connectionWithScopes) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: (connectionWithScopes.accounts?.length ?? 0) > 0,
|
||||
accounts: connectionWithScopes.accounts || [],
|
||||
lastConnected: connectionWithScopes.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
})
|
||||
|
||||
return updatedServices
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return defineServices()
|
||||
}
|
||||
logger.error('Error fetching OAuth connections:', error)
|
||||
return defineServices()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all OAuth service connections with their status.
|
||||
* Returns service definitions merged with connection data.
|
||||
*/
|
||||
export function useOAuthConnections() {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.connections(),
|
||||
queryFn: ({ signal }) => fetchOAuthConnections(signal),
|
||||
staleTime: OAUTH_CONNECTIONS_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
|
||||
if (providerId === 'trello') {
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (providerId === 'shopify') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('OAuth connection error:', error)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DisconnectServiceParams {
|
||||
provider: string
|
||||
providerId?: string
|
||||
serviceId: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects an OAuth service account.
|
||||
* Performs optimistic update and rolls back on failure.
|
||||
*/
|
||||
export function useDisconnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => {
|
||||
return requestJson(disconnectOAuthContract, {
|
||||
body: {
|
||||
provider,
|
||||
providerId,
|
||||
accountId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onMutate: async ({ serviceId, accountId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
|
||||
const previousServices = queryClient.getQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections()
|
||||
)
|
||||
|
||||
if (previousServices) {
|
||||
queryClient.setQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections(),
|
||||
previousServices.map((svc) => {
|
||||
if (svc.id === serviceId) {
|
||||
const updatedAccounts =
|
||||
accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
|
||||
return {
|
||||
...svc,
|
||||
accounts: updatedAccounts,
|
||||
isConnected: updatedAccounts.length > 0,
|
||||
}
|
||||
}
|
||||
return svc
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { previousServices }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousServices) {
|
||||
queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
|
||||
}
|
||||
logger.error('Failed to disconnect service')
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Connected OAuth account for a specific provider. */
|
||||
export type { ConnectedAccount }
|
||||
|
||||
async function fetchConnectedAccounts(
|
||||
provider: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ConnectedAccount[]> {
|
||||
const data = await requestJson(listConnectedAccountsContract, {
|
||||
query: { provider },
|
||||
signal,
|
||||
})
|
||||
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.account(provider),
|
||||
queryFn: ({ signal }) => fetchConnectedAccounts(provider, signal),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: OAUTH_CONNECTED_ACCOUNTS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listOAuthCredentialsContract } from '@/lib/api/contracts'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
|
||||
export const OAUTH_CREDENTIAL_LIST_STALE_TIME = 60 * 1000
|
||||
export const OAUTH_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000
|
||||
|
||||
export const oauthCredentialKeys = {
|
||||
all: ['oauthCredentials'] as const,
|
||||
lists: () => [...oauthCredentialKeys.all, 'list'] as const,
|
||||
list: (providerId?: string, workspaceId?: string, workflowId?: string) =>
|
||||
[
|
||||
...oauthCredentialKeys.lists(),
|
||||
providerId ?? 'none',
|
||||
workspaceId ?? 'none',
|
||||
workflowId ?? 'none',
|
||||
] as const,
|
||||
details: () => [...oauthCredentialKeys.all, 'detail'] as const,
|
||||
detail: (credentialId?: string, workflowId?: string) =>
|
||||
[...oauthCredentialKeys.details(), credentialId ?? 'none', workflowId ?? 'none'] as const,
|
||||
}
|
||||
|
||||
interface FetchOAuthCredentialsParams {
|
||||
providerId: string
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentials(
|
||||
params: FetchOAuthCredentialsParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<Credential[]> {
|
||||
const { providerId, workspaceId, workflowId } = params
|
||||
if (!providerId) return []
|
||||
const data = await requestJson(listOAuthCredentialsContract, {
|
||||
signal,
|
||||
query: {
|
||||
provider: providerId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
},
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentialDetail(
|
||||
credentialId: string,
|
||||
workflowId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Credential[]> {
|
||||
if (!credentialId) return []
|
||||
const data = await requestJson(listOAuthCredentialsContract, {
|
||||
signal,
|
||||
query: {
|
||||
credentialId,
|
||||
workflowId,
|
||||
},
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
interface UseOAuthCredentialsOptions {
|
||||
enabled?: boolean
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
function resolveOptions(
|
||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
||||
): Required<UseOAuthCredentialsOptions> {
|
||||
if (typeof enabledOrOptions === 'boolean') {
|
||||
return {
|
||||
enabled: enabledOrOptions,
|
||||
workspaceId: '',
|
||||
workflowId: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: enabledOrOptions?.enabled ?? true,
|
||||
workspaceId: enabledOrOptions?.workspaceId ?? '',
|
||||
workflowId: enabledOrOptions?.workflowId ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function useOAuthCredentials(
|
||||
providerId?: string,
|
||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
||||
) {
|
||||
const { enabled, workspaceId, workflowId } = resolveOptions(enabledOrOptions)
|
||||
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.list(providerId, workspaceId, workflowId),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchOAuthCredentials(
|
||||
{
|
||||
providerId: providerId ?? '',
|
||||
workspaceId: workspaceId || undefined,
|
||||
workflowId: workflowId || undefined,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: Boolean(providerId) && enabled,
|
||||
staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOAuthCredentialDetail(
|
||||
credentialId?: string,
|
||||
workflowId?: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.detail(credentialId, workflowId),
|
||||
queryFn: ({ signal }) => fetchOAuthCredentialDetail(credentialId ?? '', workflowId, signal),
|
||||
enabled: Boolean(credentialId) && enabled,
|
||||
staleTime: OAUTH_CREDENTIAL_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCredentialName(
|
||||
credentialId?: string,
|
||||
providerId?: string,
|
||||
workflowId?: string,
|
||||
workspaceId?: string
|
||||
) {
|
||||
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
|
||||
providerId,
|
||||
{
|
||||
enabled: Boolean(providerId),
|
||||
workspaceId,
|
||||
workflowId,
|
||||
}
|
||||
)
|
||||
|
||||
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
|
||||
|
||||
const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
|
||||
shouldFetchDetail ? credentialId : undefined,
|
||||
workflowId,
|
||||
shouldFetchDetail
|
||||
)
|
||||
|
||||
// Fallback for credential blocks that have no serviceId/providerId — look up by ID directly
|
||||
const { data: workspaceCredential, isFetching: workspaceCredentialLoading } =
|
||||
useWorkspaceCredential(!providerId ? credentialId : undefined)
|
||||
|
||||
const detailCredential = foreignCredentials[0]
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
|
||||
const displayName =
|
||||
selectedCredential?.name ?? detailCredential?.name ?? workspaceCredential?.displayName ?? null
|
||||
|
||||
return {
|
||||
displayName,
|
||||
isLoading: credentialsLoading || foreignLoading || workspaceCredentialLoading,
|
||||
hasForeignMeta,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
keepPreviousData,
|
||||
type UseQueryResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
cancelInvitationContract,
|
||||
resendInvitationContract,
|
||||
updateInvitationContract,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import {
|
||||
createOrganizationContract,
|
||||
getMyMemberCreditsContract,
|
||||
getOrganizationMemberUsageLimitContract,
|
||||
getOrganizationRosterContract,
|
||||
inviteOrganizationMembersContract,
|
||||
listOrganizationMembersContract,
|
||||
type MyMemberCreditsData,
|
||||
type OrganizationMembersResponse,
|
||||
type OrganizationMemberUsageLimitData,
|
||||
type OrganizationRoster,
|
||||
type RosterMember,
|
||||
type RosterPendingInvitation,
|
||||
type RosterWorkspaceAccess,
|
||||
removeOrganizationMemberContract,
|
||||
transferOwnershipContract,
|
||||
updateOrganizationContract,
|
||||
updateOrganizationMemberRoleContract,
|
||||
updateOrganizationMemberUsageLimitContract,
|
||||
updateOrganizationUsageLimitContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import {
|
||||
getOrganizationBillingContract,
|
||||
type OrganizationBillingApiResponse,
|
||||
} from '@/lib/api/contracts/subscription'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('OrganizationQueries')
|
||||
const invitationListsKey = ['invitations', 'list'] as const
|
||||
|
||||
export const ORGANIZATION_ROSTER_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_LIST_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000
|
||||
export const ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME = 30 * 1000
|
||||
|
||||
type OrganizationSubscriptionCandidate = {
|
||||
id: string
|
||||
referenceId: string
|
||||
status: string
|
||||
plan: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
}
|
||||
|
||||
type OrganizationBillingQueryResult = UseQueryResult<OrganizationBillingApiResponse | null, Error>
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isOrganizationSubscriptionCandidate(
|
||||
value: unknown
|
||||
): value is OrganizationSubscriptionCandidate {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.referenceId === 'string' &&
|
||||
typeof value.status === 'string' &&
|
||||
typeof value.plan === 'string' &&
|
||||
(value.cancelAtPeriodEnd === undefined || typeof value.cancelAtPeriodEnd === 'boolean') &&
|
||||
(value.periodEnd === undefined ||
|
||||
typeof value.periodEnd === 'number' ||
|
||||
value.periodEnd instanceof Date) &&
|
||||
(value.trialEnd === undefined ||
|
||||
typeof value.trialEnd === 'number' ||
|
||||
value.trialEnd instanceof Date)
|
||||
)
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factories for organization-related queries
|
||||
* This ensures consistent cache invalidation across the app
|
||||
*/
|
||||
export const organizationKeys = {
|
||||
all: ['organizations'] as const,
|
||||
lists: () => [...organizationKeys.all, 'list'] as const,
|
||||
details: () => [...organizationKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...organizationKeys.details(), id] as const,
|
||||
subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const,
|
||||
billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const,
|
||||
members: (id: string) => [...organizationKeys.detail(id), 'members'] as const,
|
||||
memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const,
|
||||
memberUsageLimit: (id: string, userId: string) =>
|
||||
[...organizationKeys.detail(id), 'member-usage-limit', userId] as const,
|
||||
roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const,
|
||||
myMemberCredits: (workspaceId: string) =>
|
||||
[...organizationKeys.all, 'my-member-credits', workspaceId] as const,
|
||||
}
|
||||
|
||||
export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess }
|
||||
|
||||
async function fetchOrganizationRoster(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationRoster | null> {
|
||||
if (!orgId) return null
|
||||
|
||||
try {
|
||||
const payload = await requestJson(getOrganizationRosterContract, {
|
||||
params: { id: orgId },
|
||||
signal,
|
||||
})
|
||||
return payload.data
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && (error.status === 403 || error.status === 404)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function useOrganizationRoster(orgId: string | undefined | null) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.roster(orgId ?? ''),
|
||||
queryFn: ({ signal }) => fetchOrganizationRoster(orgId as string, signal),
|
||||
enabled: !!orgId,
|
||||
staleTime: ORGANIZATION_ROSTER_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all organizations for the current user
|
||||
* Note: Billing data is fetched separately via useSubscriptionData() to avoid duplicate calls
|
||||
* Note: better-auth client does not support AbortSignal, so signal is accepted but not forwarded
|
||||
*/
|
||||
async function fetchOrganizations(_signal?: AbortSignal) {
|
||||
const [orgsResponse, activeOrgResponse] = await Promise.all([
|
||||
client.organization.list(),
|
||||
client.organization.getFullOrganization(),
|
||||
])
|
||||
|
||||
return {
|
||||
organizations: orgsResponse.data || [],
|
||||
activeOrganization: activeOrgResponse.data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all organizations
|
||||
*/
|
||||
export function useOrganizations() {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.lists(),
|
||||
queryFn: ({ signal }) => fetchOrganizations(signal),
|
||||
staleTime: ORGANIZATION_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific organization by ID.
|
||||
*
|
||||
* `getFullOrganization` defaults to the active organization when no
|
||||
* `organizationId` is supplied; passing `orgId` through scopes the result to the
|
||||
* requested org so it is cached under the correct `organizationKeys.detail(orgId)`
|
||||
* (no cross-org cache collision). The active-org caller passes the active org's
|
||||
* id, so its behavior is unchanged.
|
||||
*/
|
||||
async function fetchOrganization(orgId: string, _signal?: AbortSignal) {
|
||||
const response = await client.organization.getFullOrganization({
|
||||
query: { organizationId: orgId },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific organization
|
||||
*/
|
||||
export function useOrganization(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.detail(orgId),
|
||||
queryFn: ({ signal }) => fetchOrganization(orgId, signal),
|
||||
enabled: !!orgId,
|
||||
staleTime: ORGANIZATION_DETAIL_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization subscription data
|
||||
*/
|
||||
async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSignal) {
|
||||
if (!orgId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await client.subscription.list({
|
||||
query: { referenceId: orgId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
logger.error('Error fetching organization subscription', { error: response.error })
|
||||
return null
|
||||
}
|
||||
|
||||
// Any paid subscription attached to the org counts as its active sub.
|
||||
// Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`).
|
||||
// This intentionally includes `pro_*` plans that have been transferred
|
||||
// to the org — they are pooled org-scoped subscriptions.
|
||||
const rawSubscriptions: unknown = response.data
|
||||
const entitled = (Array.isArray(rawSubscriptions) ? rawSubscriptions : [])
|
||||
.filter(isOrganizationSubscriptionCandidate)
|
||||
.filter((sub) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan))
|
||||
const enterpriseSubscription = entitled.find((sub) => isEnterprise(sub.plan))
|
||||
const teamSubscription = entitled.find((sub) => isTeam(sub.plan))
|
||||
const proSubscription = entitled.find((sub) => !isEnterprise(sub.plan) && !isTeam(sub.plan))
|
||||
const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription
|
||||
|
||||
return activeSubscription || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization subscription
|
||||
*/
|
||||
export function useOrganizationSubscription(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.subscription(orgId),
|
||||
queryFn: ({ signal }) => fetchOrganizationSubscription(orgId, signal),
|
||||
enabled: !!orgId,
|
||||
retry: false,
|
||||
staleTime: ORGANIZATION_SUBSCRIPTION_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization billing data
|
||||
*/
|
||||
async function fetchOrganizationBilling(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationBillingApiResponse | null> {
|
||||
try {
|
||||
return await requestJson(getOrganizationBillingContract, {
|
||||
query: { context: 'organization', id: orgId },
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization billing data
|
||||
*/
|
||||
export function useOrganizationBilling(
|
||||
orgId: string,
|
||||
options?: { enabled?: boolean }
|
||||
): OrganizationBillingQueryResult {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.billing(orgId),
|
||||
queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal),
|
||||
enabled: !!orgId && (options?.enabled ?? true),
|
||||
retry: false,
|
||||
staleTime: ORGANIZATION_BILLING_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization member usage data
|
||||
*/
|
||||
async function fetchOrganizationMembers(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationMembersResponse> {
|
||||
try {
|
||||
return await requestJson(listOrganizationMembersContract, {
|
||||
params: { id: orgId },
|
||||
query: { include: 'usage' },
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
userRole: 'member',
|
||||
hasAdminAccess: false,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization members with usage data
|
||||
*/
|
||||
export function useOrganizationMembers(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.memberUsage(orgId),
|
||||
queryFn: ({ signal }) => fetchOrganizationMembers(orgId, signal),
|
||||
enabled: !!orgId,
|
||||
staleTime: ORGANIZATION_MEMBERS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization usage limit mutation with optimistic updates
|
||||
*/
|
||||
type UpdateOrganizationUsageLimitParams = Pick<
|
||||
ContractBodyInput<typeof updateOrganizationUsageLimitContract>,
|
||||
'organizationId' | 'limit'
|
||||
>
|
||||
|
||||
export function useUpdateOrganizationUsageLimit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, limit }: UpdateOrganizationUsageLimitParams) => {
|
||||
return requestJson(updateOrganizationUsageLimitContract, {
|
||||
body: { context: 'organization', organizationId, limit },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ organizationId, limit }) => {
|
||||
await queryClient.cancelQueries({ queryKey: organizationKeys.billing(organizationId) })
|
||||
await queryClient.cancelQueries({ queryKey: organizationKeys.subscription(organizationId) })
|
||||
|
||||
const previousBillingData = queryClient.getQueryData(organizationKeys.billing(organizationId))
|
||||
const previousSubscriptionData = queryClient.getQueryData(
|
||||
organizationKeys.subscription(organizationId)
|
||||
)
|
||||
|
||||
queryClient.setQueryData<unknown>(
|
||||
organizationKeys.billing(organizationId),
|
||||
(old: unknown) => {
|
||||
if (!isRecord(old) || !isRecord(old.data)) return old
|
||||
const usage = isRecord(old.data.usage) ? old.data.usage : {}
|
||||
const currentUsage =
|
||||
readNumber(old.data.currentUsage) ??
|
||||
readNumber(usage.current) ??
|
||||
readNumber(old.data.totalCurrentUsage) ??
|
||||
0
|
||||
const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
|
||||
|
||||
return {
|
||||
...old,
|
||||
data: {
|
||||
...old.data,
|
||||
totalUsageLimit: limit,
|
||||
usage: {
|
||||
...usage,
|
||||
limit,
|
||||
percentUsed: newPercentUsed,
|
||||
},
|
||||
percentUsed: newPercentUsed,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { previousBillingData, previousSubscriptionData, organizationId }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousBillingData && context?.organizationId) {
|
||||
queryClient.setQueryData(
|
||||
organizationKeys.billing(context.organizationId),
|
||||
context.previousBillingData
|
||||
)
|
||||
}
|
||||
if (context?.previousSubscriptionData && context?.organizationId) {
|
||||
queryClient.setQueryData(
|
||||
organizationKeys.subscription(context.organizationId),
|
||||
context.previousSubscriptionData
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.subscription(variables.organizationId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member mutation
|
||||
*/
|
||||
type InviteMemberParams = Pick<
|
||||
ContractBodyInput<typeof inviteOrganizationMembersContract>,
|
||||
'emails' | 'workspaceInvitations'
|
||||
> & {
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useInviteMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => {
|
||||
/**
|
||||
* Partial batches return HTTP 207 with `success: false` and a `data`
|
||||
* payload (some invited/added, some failed). `requestJson` only throws on
|
||||
* >= 400 (e.g. the total-failure 502 / validation 400 paths), so partials
|
||||
* resolve here and the caller reports successes + per-email failures from
|
||||
* `data` instead of surfacing a single generic error.
|
||||
*/
|
||||
return requestJson(inviteOrganizationMembersContract, {
|
||||
params: { id: orgId },
|
||||
query: { batch: true },
|
||||
body: {
|
||||
emails,
|
||||
workspaceInvitations,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
// Existing members may have been added directly to selected workspaces.
|
||||
for (const grant of variables.workspaceInvitations ?? []) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(grant.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.members(grant.workspaceId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member mutation
|
||||
*/
|
||||
interface RemoveMemberParams {
|
||||
memberId: string
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useRemoveMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ memberId, orgId }: RemoveMemberParams) => {
|
||||
return requestJson(removeOrganizationMemberContract, {
|
||||
params: { id: orgId, memberId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: invitationListsKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateMemberRoleParams {
|
||||
orgId: string
|
||||
userId: string
|
||||
role: ContractBodyInput<typeof updateOrganizationMemberRoleContract>['role']
|
||||
}
|
||||
|
||||
export function useUpdateOrganizationMemberRole() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, userId, role }: UpdateMemberRoleParams) => {
|
||||
return requestJson(updateOrganizationMemberRoleContract, {
|
||||
params: { id: orgId, memberId: userId },
|
||||
body: { role },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchOrganizationMemberUsageLimit(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationMemberUsageLimitData> {
|
||||
const response = await requestJson(getOrganizationMemberUsageLimitContract, {
|
||||
params: { id: orgId, memberId: userId },
|
||||
signal,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single member's per-org credit usage + cap (values in credits).
|
||||
* Lazily enabled so it only fires while the Manage Credits modal is open.
|
||||
*/
|
||||
export function useOrganizationMemberUsageLimit(orgId?: string, userId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.memberUsageLimit(orgId ?? '', userId ?? ''),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchOrganizationMemberUsageLimit(orgId as string, userId as string, signal),
|
||||
enabled: Boolean(orgId) && Boolean(userId) && enabled,
|
||||
staleTime: ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateMemberUsageLimitParams {
|
||||
orgId: string
|
||||
userId: string
|
||||
creditLimit: ContractBodyInput<typeof updateOrganizationMemberUsageLimitContract>['creditLimit']
|
||||
}
|
||||
|
||||
export function useUpdateOrganizationMemberUsageLimit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, userId, creditLimit }: UpdateMemberUsageLimitParams) => {
|
||||
return requestJson(updateOrganizationMemberUsageLimitContract, {
|
||||
params: { id: orgId, memberId: userId },
|
||||
body: { creditLimit },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.memberUsageLimit(variables.orgId, variables.userId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchMyMemberCredits(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MyMemberCreditsData> {
|
||||
const response = await requestJson(getMyMemberCreditsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's OWN per-member credit usage + cap for a workspace's organization.
|
||||
* `creditLimit` is null when no per-member cap applies (non-hosted, non-org
|
||||
* workspace, or no cap set) — callers then fall back to the plan-level view.
|
||||
*/
|
||||
export function useMyMemberCredits(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.myMemberCredits(workspaceId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMyMemberCredits(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
type TransferOwnershipParams = {
|
||||
orgId: string
|
||||
} & ContractBodyInput<typeof transferOwnershipContract>
|
||||
|
||||
export function useTransferOwnership() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, newOwnerUserId, alsoLeave = false }: TransferOwnershipParams) => {
|
||||
return requestJson(transferOwnershipContract, {
|
||||
params: { id: orgId },
|
||||
body: { newOwnerUserId, alsoLeave },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateInvitationParams = {
|
||||
orgId: string
|
||||
invitationId: string
|
||||
} & ContractBodyInput<typeof updateInvitationContract>
|
||||
|
||||
export function useUpdateInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId, role, grants }: UpdateInvitationParams) => {
|
||||
return requestJson(updateInvitationContract, {
|
||||
params: { id: invitationId },
|
||||
body: { role, grants },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel invitation mutation
|
||||
*/
|
||||
interface CancelInvitationParams {
|
||||
invitationId: string
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useCancelInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
|
||||
return requestJson(cancelInvitationContract, {
|
||||
params: { id: invitationId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: invitationListsKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend invitation mutation
|
||||
*/
|
||||
interface ResendInvitationParams {
|
||||
invitationId: string
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useResendInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
|
||||
return requestJson(resendInvitationContract, {
|
||||
params: { id: invitationId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization settings mutation
|
||||
*/
|
||||
type UpdateOrganizationParams = {
|
||||
orgId: string
|
||||
} & ContractBodyInput<typeof updateOrganizationContract>
|
||||
|
||||
export function useUpdateOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, ...updates }: UpdateOrganizationParams) => {
|
||||
return requestJson(updateOrganizationContract, {
|
||||
params: { id: orgId },
|
||||
body: updates,
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization mutation
|
||||
*/
|
||||
type CreateOrganizationParams = Pick<
|
||||
ContractBodyInput<typeof createOrganizationContract>,
|
||||
'slug'
|
||||
> & {
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useCreateOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, slug }: CreateOrganizationParams) => {
|
||||
const data = await requestJson(createOrganizationContract, {
|
||||
body: {
|
||||
name,
|
||||
slug: slug || name.toLowerCase().replace(/\s+/g, '-'),
|
||||
},
|
||||
})
|
||||
|
||||
await client.organization.setActive({
|
||||
organizationId: data.organizationId,
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { derivePlanView, type PlanView } from '@/lib/billing/client'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
/**
|
||||
* Result of {@link usePlanView}. `creditBalance` and `hasData` are surfaced so
|
||||
* credit-display surfaces don't need a second `useSubscriptionData` call.
|
||||
*/
|
||||
export interface UsePlanViewResult {
|
||||
planView: PlanView
|
||||
creditBalance: number
|
||||
isLoading: boolean
|
||||
hasData: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query wrapper over {@link useSubscriptionData} that exposes the
|
||||
* canonical {@link PlanView}. The single hook every billing surface (upgrade
|
||||
* page, home credits chip, sidebar, settings billing page) consumes for
|
||||
* plan-derived UI decisions.
|
||||
*/
|
||||
export function usePlanView(options?: { includeOrg?: boolean }): UsePlanViewResult {
|
||||
const { data, isLoading } = useSubscriptionData(options)
|
||||
const plan = data?.data?.plan
|
||||
const planView = useMemo(() => derivePlanView(plan), [plan])
|
||||
return {
|
||||
planView,
|
||||
creditBalance: data?.data?.creditBalance ?? 0,
|
||||
isLoading,
|
||||
hasData: Boolean(data?.data),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getBaseProviderModelsContract,
|
||||
getBasetenProviderModelsContract,
|
||||
getFireworksProviderModelsContract,
|
||||
getLitellmProviderModelsContract,
|
||||
getOllamaCloudProviderModelsContract,
|
||||
getOllamaProviderModelsContract,
|
||||
getOpenRouterProviderModelsContract,
|
||||
getTogetherProviderModelsContract,
|
||||
getVllmProviderModelsContract,
|
||||
type ProviderModelsResponse,
|
||||
} from '@/lib/api/contracts/providers'
|
||||
import type { ProviderName } from '@/stores/providers'
|
||||
|
||||
const logger = createLogger('ProviderModelsQuery')
|
||||
|
||||
export const PROVIDER_MODELS_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const providerKeys = {
|
||||
all: ['provider-models'] as const,
|
||||
lists: () => [...providerKeys.all, 'list'] as const,
|
||||
list: (provider: string, workspaceId?: string) =>
|
||||
[...providerKeys.lists(), provider, workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchProviderModels(
|
||||
provider: ProviderName,
|
||||
signal?: AbortSignal,
|
||||
workspaceId?: string
|
||||
): Promise<ProviderModelsResponse> {
|
||||
try {
|
||||
const data = await requestProviderModels(provider, signal, workspaceId)
|
||||
const models: string[] = Array.isArray(data.models) ? data.models : []
|
||||
const uniqueModels = provider === 'openrouter' ? Array.from(new Set(models)) : models
|
||||
|
||||
return {
|
||||
models: uniqueModels,
|
||||
modelInfo: data.modelInfo,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch ${provider} models`, {
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestProviderModels(
|
||||
provider: ProviderName,
|
||||
signal?: AbortSignal,
|
||||
workspaceId?: string
|
||||
): Promise<ProviderModelsResponse> {
|
||||
switch (provider) {
|
||||
case 'base':
|
||||
return requestJson(getBaseProviderModelsContract, { signal })
|
||||
case 'ollama':
|
||||
return requestJson(getOllamaProviderModelsContract, { signal })
|
||||
case 'ollama-cloud':
|
||||
return requestJson(getOllamaCloudProviderModelsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
case 'vllm':
|
||||
return requestJson(getVllmProviderModelsContract, { signal })
|
||||
case 'litellm':
|
||||
return requestJson(getLitellmProviderModelsContract, { signal })
|
||||
case 'openrouter':
|
||||
return requestJson(getOpenRouterProviderModelsContract, { signal })
|
||||
case 'fireworks':
|
||||
return requestJson(getFireworksProviderModelsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
case 'together':
|
||||
return requestJson(getTogetherProviderModelsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
case 'baseten':
|
||||
return requestJson(getBasetenProviderModelsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useProviderModels(provider: ProviderName, workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: providerKeys.list(provider, workspaceId),
|
||||
queryFn: ({ signal }) => fetchProviderModels(provider, signal, workspaceId),
|
||||
staleTime: PROVIDER_MODELS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { toast } from '@sim/emcn'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type AuthenticatePublicFileResponse,
|
||||
authenticatePublicFileContract,
|
||||
getFileShareContract,
|
||||
requestPublicFileOtpContract,
|
||||
type ShareRecord,
|
||||
type UpsertFileShareBody,
|
||||
upsertFileShareContract,
|
||||
type VerifyPublicFileOtpResponse,
|
||||
verifyPublicFileOtpContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
export const FILE_SHARE_STALE_TIME = 30 * 1000
|
||||
|
||||
/**
|
||||
* Query key factories for public shares
|
||||
*/
|
||||
export const shareKeys = {
|
||||
all: ['publicShares'] as const,
|
||||
details: () => [...shareKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId: string, fileId: string) =>
|
||||
[...shareKeys.details(), workspaceId, fileId] as const,
|
||||
}
|
||||
|
||||
async function fetchFileShare(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ShareRecord | null> {
|
||||
const data = await requestJson(getFileShareContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
signal,
|
||||
})
|
||||
return data.share
|
||||
}
|
||||
|
||||
export function useFileShare(workspaceId: string, fileId: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: shareKeys.detail(workspaceId, fileId),
|
||||
queryFn: ({ signal }) => fetchFileShare(workspaceId, fileId, signal),
|
||||
enabled: Boolean(workspaceId) && Boolean(fileId) && (options?.enabled ?? true),
|
||||
staleTime: FILE_SHARE_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
interface UpsertFileShareVariables extends UpsertFileShareBody {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
}
|
||||
|
||||
export function useUpsertFileShare() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, fileId, ...body }: UpsertFileShareVariables) =>
|
||||
requestJson(upsertFileShareContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
body,
|
||||
}),
|
||||
onSuccess: (data, { workspaceId, fileId }) => {
|
||||
queryClient.setQueryData(shareKeys.detail(workspaceId, fileId), data.share)
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie on the public
|
||||
* file page. On success the page should `router.refresh()` to re-render the
|
||||
* now-authorized viewer.
|
||||
*/
|
||||
export function usePublicFileAuth(token: string) {
|
||||
return useMutation<AuthenticatePublicFileResponse, Error, { password: string }>({
|
||||
mutationFn: ({ password }) =>
|
||||
requestJson(authenticatePublicFileContract, {
|
||||
params: { token },
|
||||
body: { password },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Requests a verification code for an email-gated share (initial send + resend). */
|
||||
export function usePublicFileOtpRequest(token: string) {
|
||||
return useMutation<{ message: string }, Error, { email: string }>({
|
||||
mutationFn: ({ email }) =>
|
||||
requestJson(requestPublicFileOtpContract, {
|
||||
params: { token },
|
||||
body: { email },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the OTP for an email-gated share. On success the server sets the
|
||||
* `file_auth_{shareId}` cookie; the page should then `router.refresh()`.
|
||||
*/
|
||||
export function usePublicFileOtpVerify(token: string) {
|
||||
return useMutation<VerifyPublicFileOtpResponse, Error, { email: string; otp: string }>({
|
||||
mutationFn: ({ email, otp }) =>
|
||||
requestJson(verifyPublicFileOtpContract, {
|
||||
params: { token },
|
||||
body: { email, otp },
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getPauseContextDetailContract,
|
||||
resumeWorkflowExecutionContract,
|
||||
} from '@/lib/api/contracts/workflows'
|
||||
import type { ResumeStatus } from '@/executor/types'
|
||||
|
||||
export const RESUME_EXECUTION_DETAIL_STALE_TIME = 30 * 1000
|
||||
|
||||
export const resumeKeys = {
|
||||
all: ['resume-execution'] as const,
|
||||
executions: () => [...resumeKeys.all, 'execution'] as const,
|
||||
execution: (workflowId?: string, executionId?: string) =>
|
||||
[...resumeKeys.executions(), workflowId ?? '', executionId ?? ''] as const,
|
||||
contexts: () => [...resumeKeys.all, 'context'] as const,
|
||||
context: (workflowId?: string, executionId?: string, contextId?: string) =>
|
||||
[...resumeKeys.contexts(), workflowId ?? '', executionId ?? '', contextId ?? ''] as const,
|
||||
}
|
||||
|
||||
export interface ResumeLinks {
|
||||
apiUrl: string
|
||||
uiUrl: string
|
||||
contextId: string
|
||||
executionId: string
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
export interface ResumeQueueEntrySummary {
|
||||
id: string
|
||||
contextId: string
|
||||
status: string
|
||||
queuedAt: string | null
|
||||
claimedAt: string | null
|
||||
completedAt: string | null
|
||||
failureReason: string | null
|
||||
newExecutionId: string
|
||||
resumeInput: any
|
||||
}
|
||||
|
||||
export interface PausePointWithQueue {
|
||||
contextId: string
|
||||
triggerBlockId?: string
|
||||
blockId?: string
|
||||
response: any
|
||||
registeredAt: string
|
||||
resumeStatus: ResumeStatus
|
||||
snapshotReady: boolean
|
||||
resumeLinks?: ResumeLinks
|
||||
queuePosition?: number | null
|
||||
latestResumeEntry?: ResumeQueueEntrySummary | null
|
||||
parallelScope?: any
|
||||
loopScope?: any
|
||||
pauseKind?: 'human' | 'time'
|
||||
resumeAt?: string
|
||||
}
|
||||
|
||||
export interface PausedExecutionSummary {
|
||||
id: string
|
||||
workflowId: string
|
||||
executionId: string
|
||||
status: string
|
||||
totalPauseCount: number
|
||||
resumedCount: number
|
||||
pausedAt: string | null
|
||||
updatedAt: string | null
|
||||
expiresAt: string | null
|
||||
metadata: Record<string, any> | null
|
||||
triggerIds: string[]
|
||||
pausePoints: PausePointWithQueue[]
|
||||
}
|
||||
|
||||
export interface PausedExecutionDetail extends PausedExecutionSummary {
|
||||
executionSnapshot: any
|
||||
queue: ResumeQueueEntrySummary[]
|
||||
}
|
||||
|
||||
export interface PauseContextDetail {
|
||||
execution: PausedExecutionSummary
|
||||
pausePoint: PausePointWithQueue
|
||||
queue: ResumeQueueEntrySummary[]
|
||||
activeResumeEntry?: ResumeQueueEntrySummary | null
|
||||
}
|
||||
|
||||
export interface ResumeContextResult {
|
||||
ok: boolean
|
||||
payload: {
|
||||
status?: string
|
||||
queuePosition?: number | null
|
||||
error?: string
|
||||
message?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface ResumeContextVariables {
|
||||
workflowId: string
|
||||
executionId: string
|
||||
contextId: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the paused execution detail (all pause points for an execution). The
|
||||
* contract models pause points loosely (`z.record`); the resume UI works against
|
||||
* the richer `PausedExecutionDetail` interface, hence the bridging cast.
|
||||
*/
|
||||
export function useResumeExecutionDetail(
|
||||
workflowId: string,
|
||||
executionId: string,
|
||||
initialData?: PausedExecutionDetail
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: resumeKeys.execution(workflowId, executionId),
|
||||
queryFn: async ({ signal }): Promise<PausedExecutionDetail> => {
|
||||
const raw = await requestJson(resumeWorkflowExecutionContract, {
|
||||
params: { workflowId, executionId },
|
||||
signal,
|
||||
})
|
||||
// double-cast-allowed: contract models pause points as z.record; the resume UI uses the richer PausedExecutionDetail interface
|
||||
return raw as unknown as PausedExecutionDetail
|
||||
},
|
||||
enabled: Boolean(workflowId && executionId),
|
||||
staleTime: RESUME_EXECUTION_DETAIL_STALE_TIME,
|
||||
initialData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the detail for a single pause context. Returns `null` when the context
|
||||
* no longer exists (404). `staleTime: 0` because pause state is live.
|
||||
*/
|
||||
export function usePauseContextDetail(workflowId: string, executionId: string, contextId?: string) {
|
||||
return useQuery({
|
||||
queryKey: resumeKeys.context(workflowId, executionId, contextId),
|
||||
queryFn: async ({ signal }): Promise<PauseContextDetail | null> => {
|
||||
try {
|
||||
const raw = await requestJson(getPauseContextDetailContract, {
|
||||
params: { workflowId, executionId, contextId: contextId as string },
|
||||
signal,
|
||||
})
|
||||
// double-cast-allowed: contract models the pause point as z.record; the resume UI uses the richer PauseContextDetail interface
|
||||
return raw as unknown as PauseContextDetail
|
||||
} catch (error) {
|
||||
if (isApiClientError(error) && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
enabled: Boolean(workflowId && executionId && contextId),
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a resume for a pause context and invalidates the execution + context
|
||||
* queries so the cache reconciles with the server. The POST stays a raw fetch:
|
||||
* the route reads the body with a tolerant JSON parse and the contract has no
|
||||
* body schema, so it cannot go through `requestJson`.
|
||||
*/
|
||||
export function useResumeContext() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
executionId,
|
||||
contextId,
|
||||
input,
|
||||
}: ResumeContextVariables): Promise<ResumeContextResult> => {
|
||||
// boundary-raw-fetch: resume-context POST contract has no body schema (route uses tolerant raw JSON parse for resume input forwarded to PauseResumeManager)
|
||||
const response = await fetch(`/api/resume/${workflowId}/${executionId}/${contextId}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input ? { input } : {}),
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
return { ok: response.ok, payload }
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: resumeKeys.execution(variables.workflowId, variables.executionId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: resumeKeys.context(
|
||||
variables.workflowId,
|
||||
variables.executionId,
|
||||
variables.contextId
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { deployWorkflowContract } from '@/lib/api/contracts/deployments'
|
||||
import {
|
||||
type CreateScheduleBody,
|
||||
createScheduleContract,
|
||||
deleteScheduleContract,
|
||||
disableScheduleContract,
|
||||
excludeOccurrenceContract,
|
||||
getScheduleByIdContract,
|
||||
getScheduleContract,
|
||||
listWorkspaceSchedulesContract,
|
||||
reactivateScheduleContract,
|
||||
type UpdateScheduleBody,
|
||||
updateScheduleContract,
|
||||
type WorkflowScheduleRow,
|
||||
type WorkspaceScheduleRow,
|
||||
} from '@/lib/api/contracts/schedules'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
|
||||
const logger = createLogger('ScheduleQueries')
|
||||
|
||||
export const SCHEDULE_LIST_STALE_TIME = 30 * 1000
|
||||
export const SCHEDULE_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const SCHEDULE_BLOCK_STALE_TIME = 30 * 1000
|
||||
|
||||
export const scheduleKeys = {
|
||||
all: ['schedules'] as const,
|
||||
lists: () => [...scheduleKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...scheduleKeys.lists(), workspaceId] as const,
|
||||
details: () => [...scheduleKeys.all, 'detail'] as const,
|
||||
schedule: (workflowId: string, blockId: string) =>
|
||||
[...scheduleKeys.details(), workflowId, blockId] as const,
|
||||
byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const,
|
||||
}
|
||||
|
||||
export type ScheduleData = WorkflowScheduleRow
|
||||
export type WorkspaceScheduleData = WorkspaceScheduleRow
|
||||
|
||||
export interface ScheduleInfo {
|
||||
id: string
|
||||
status: ScheduleData['status']
|
||||
scheduleTiming: string
|
||||
nextRunAt: string | null
|
||||
lastRanAt: string | null
|
||||
timezone: string
|
||||
isDisabled: boolean
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches schedule data for a specific workflow block
|
||||
*/
|
||||
async function fetchSchedule(
|
||||
workflowId: string,
|
||||
blockId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ScheduleData | null> {
|
||||
try {
|
||||
const data = await requestJson(getScheduleContract, {
|
||||
query: { workflowId, blockId },
|
||||
signal,
|
||||
})
|
||||
return data.schedule || null
|
||||
} catch (error) {
|
||||
if (isApiClientError(error) && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all schedules for a workspace.
|
||||
*/
|
||||
export function useWorkspaceSchedules(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: scheduleKeys.list(workspaceId ?? ''),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!workspaceId) throw new Error('Workspace ID required')
|
||||
|
||||
const data = await requestJson(listWorkspaceSchedulesContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.schedules || []
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: SCHEDULE_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single schedule (job) by id. Used by the mothership resource viewer so
|
||||
* opening a scheduled-task artifact does a lightweight by-id read instead of the
|
||||
* whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat
|
||||
* stream connection and stalled start/resume).
|
||||
*/
|
||||
export function useScheduleById(scheduleId?: string) {
|
||||
return useQuery({
|
||||
queryKey: scheduleKeys.byId(scheduleId ?? ''),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!scheduleId) throw new Error('Schedule ID required')
|
||||
|
||||
const data = await requestJson(getScheduleByIdContract, {
|
||||
params: { id: scheduleId },
|
||||
signal,
|
||||
})
|
||||
return data.schedule
|
||||
},
|
||||
enabled: Boolean(scheduleId),
|
||||
staleTime: SCHEDULE_DETAIL_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch schedule data for a workflow block
|
||||
*/
|
||||
export function useScheduleQuery(
|
||||
workflowId: string | undefined,
|
||||
blockId: string | undefined,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: scheduleKeys.schedule(workflowId ?? '', blockId ?? ''),
|
||||
queryFn: ({ signal }) => fetchSchedule(workflowId!, blockId!, signal),
|
||||
enabled: !!workflowId && !!blockId && (options?.enabled ?? true),
|
||||
staleTime: SCHEDULE_BLOCK_STALE_TIME,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get processed schedule info with human-readable timing
|
||||
*/
|
||||
export function useScheduleInfo(
|
||||
workflowId: string | undefined,
|
||||
blockId: string | undefined,
|
||||
blockType: string,
|
||||
options?: { timezone?: string }
|
||||
): {
|
||||
scheduleInfo: ScheduleInfo | null
|
||||
isLoading: boolean
|
||||
refetch: () => void
|
||||
} {
|
||||
const isScheduleBlock = blockType === 'schedule'
|
||||
|
||||
const { data, isLoading, refetch } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: isScheduleBlock,
|
||||
})
|
||||
|
||||
if (!data) {
|
||||
return { scheduleInfo: null, isLoading, refetch }
|
||||
}
|
||||
|
||||
const timezone = options?.timezone || data.timezone || 'UTC'
|
||||
const scheduleTiming = data.cronExpression
|
||||
? parseCronToHumanReadable(data.cronExpression, timezone)
|
||||
: 'Unknown schedule'
|
||||
|
||||
return {
|
||||
scheduleInfo: {
|
||||
id: data.id,
|
||||
status: data.status,
|
||||
scheduleTiming,
|
||||
nextRunAt: data.nextRunAt,
|
||||
lastRanAt: data.lastRanAt,
|
||||
timezone,
|
||||
isDisabled: data.status === 'disabled',
|
||||
failedCount: data.failedCount || 0,
|
||||
},
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to reactivate a disabled schedule
|
||||
*/
|
||||
export function useReactivateSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workflowId,
|
||||
blockId,
|
||||
workspaceId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
workflowId: string
|
||||
blockId: string
|
||||
workspaceId?: string
|
||||
}) => {
|
||||
await requestJson(reactivateScheduleContract, {
|
||||
params: { id: scheduleId },
|
||||
body: { action: 'reactivate' },
|
||||
})
|
||||
|
||||
return { workflowId, blockId, workspaceId }
|
||||
},
|
||||
onSuccess: ({ workflowId, blockId }) => {
|
||||
logger.info('Schedule reactivated', { workflowId, blockId })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to reactivate schedule', { error })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
const { workflowId, blockId, workspaceId } = data
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
|
||||
workspaceId
|
||||
? queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
||||
: Promise.resolve(),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to disable an active schedule or job
|
||||
*/
|
||||
export function useDisableSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workspaceId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
workspaceId: string
|
||||
}) => {
|
||||
await requestJson(disableScheduleContract, {
|
||||
params: { id: scheduleId },
|
||||
body: { action: 'disable' },
|
||||
})
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task paused')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to disable schedule', { error })
|
||||
toast.error("Couldn't pause task", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to resume (reactivate) a paused standalone job schedule. Keyed by
|
||||
* `workspaceId` so it invalidates the workspace list; the workflow-block variant
|
||||
* {@link useReactivateSchedule} keys by `workflowId`/`blockId` instead. Resuming
|
||||
* recomputes `nextRunAt` from the schedule's cron, so it applies to recurring
|
||||
* tasks only — one-time tasks carry no cadence to resume.
|
||||
*/
|
||||
export function useResumeSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workspaceId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
workspaceId: string
|
||||
}) => {
|
||||
await requestJson(reactivateScheduleContract, {
|
||||
params: { id: scheduleId },
|
||||
body: { action: 'reactivate' },
|
||||
})
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task resumed')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to resume schedule', { error })
|
||||
toast.error("Couldn't resume task", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to delete a schedule or job
|
||||
*/
|
||||
export function useDeleteSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workspaceId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
workspaceId: string
|
||||
}) => {
|
||||
await requestJson(deleteScheduleContract, {
|
||||
params: { id: scheduleId },
|
||||
})
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task deleted')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete schedule', { error })
|
||||
toast.error("Couldn't delete task", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to delete a single occurrence of a recurring task (gcal "this
|
||||
* event"). The whole series is deleted via {@link useDeleteSchedule} instead.
|
||||
*/
|
||||
export function useExcludeOccurrence() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
occurrence,
|
||||
workspaceId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
occurrence: string
|
||||
workspaceId: string
|
||||
}) => {
|
||||
await requestJson(excludeOccurrenceContract, {
|
||||
params: { id: scheduleId },
|
||||
body: { action: 'exclude_occurrence', occurrence },
|
||||
})
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Occurrence removed')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete occurrence', { error })
|
||||
toast.error("Couldn't remove occurrence", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to update fields on a standalone job schedule
|
||||
*/
|
||||
export function useUpdateSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workspaceId,
|
||||
...updates
|
||||
}: {
|
||||
scheduleId: string
|
||||
workspaceId: string
|
||||
} & Omit<UpdateScheduleBody, 'action'>) => {
|
||||
await requestJson(updateScheduleContract, {
|
||||
params: { id: scheduleId },
|
||||
body: { action: 'update', ...updates },
|
||||
})
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task updated')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update schedule', { error })
|
||||
toast.error("Couldn't update task", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to create a standalone scheduled job
|
||||
*/
|
||||
export function useCreateSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: CreateScheduleBody) => requestJson(createScheduleContract, { body }),
|
||||
onSuccess: () => {
|
||||
toast.success('Task scheduled')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to create schedule', { error })
|
||||
toast.error("Couldn't schedule task", { description: getErrorMessage(error) })
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to redeploy a workflow (which recreates the schedule)
|
||||
*/
|
||||
export function useRedeployWorkflowSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, blockId }: { workflowId: string; blockId: string }) => {
|
||||
await requestJson(deployWorkflowContract, {
|
||||
params: { id: workflowId },
|
||||
})
|
||||
|
||||
return { workflowId, blockId }
|
||||
},
|
||||
onSuccess: ({ workflowId, blockId }) => {
|
||||
logger.info('Workflow redeployed for schedule reset', { workflowId, blockId })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to redeploy workflow', { error })
|
||||
},
|
||||
onSettled: async (data) => {
|
||||
if (!data) return
|
||||
const { workflowId, blockId } = data
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }),
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
type AppSession,
|
||||
extractSessionDataFromAuthClientResult,
|
||||
} from '@/lib/auth/session-response'
|
||||
|
||||
export const SESSION_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const sessionKeys = {
|
||||
all: ['session'] as const,
|
||||
detail: () => [...sessionKeys.all, 'detail'] as const,
|
||||
}
|
||||
|
||||
async function fetchSession(signal?: AbortSignal): Promise<AppSession> {
|
||||
const res = await client.getSession({ fetchOptions: { signal } })
|
||||
return extractSessionDataFromAuthClientResult(res) as AppSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current Better Auth session via the client SDK.
|
||||
*
|
||||
* This is the Better Auth client SDK (not a same-origin `requestJson` contract),
|
||||
* so a plain `useQuery` is correct — there is no boundary contract to bind.
|
||||
*
|
||||
* `retry: false` preserves the prior fail-fast contract: an auth failure (expired
|
||||
* token, startup network partition) surfaces immediately rather than retrying a
|
||||
* request that won't succeed.
|
||||
*/
|
||||
export function useSessionQuery() {
|
||||
return useQuery({
|
||||
queryKey: sessionKeys.detail(),
|
||||
queryFn: ({ signal }) => fetchSession(signal),
|
||||
staleTime: SESSION_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
deleteSkillContract,
|
||||
listSkillsContract,
|
||||
type Skill,
|
||||
upsertSkillsContract,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('SkillsQueries')
|
||||
|
||||
export const SKILL_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
export type SkillDefinition = Skill
|
||||
|
||||
/**
|
||||
* Query key factories for skills queries
|
||||
*/
|
||||
export const skillsKeys = {
|
||||
all: ['skills'] as const,
|
||||
lists: () => [...skillsKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch skills for a workspace
|
||||
*/
|
||||
async function fetchSkills(workspaceId: string, signal?: AbortSignal): Promise<SkillDefinition[]> {
|
||||
const { data } = await requestJson(listSkillsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch skills for a workspace
|
||||
*/
|
||||
export function useSkills(workspaceId: string) {
|
||||
return useQuery<SkillDefinition[]>({
|
||||
queryKey: skillsKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchSkills(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: SKILL_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create skill mutation. On success the created skill is merged into the list
|
||||
* cache so consumers (e.g. the integration detail page's "Added" state) reflect
|
||||
* it immediately, before the invalidation refetch lands.
|
||||
*/
|
||||
interface CreateSkillParams {
|
||||
workspaceId: string
|
||||
skill: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, skill: s }: CreateSkillParams) => {
|
||||
logger.info(`Creating skill: ${s.name} in workspace ${workspaceId}`)
|
||||
|
||||
const { data } = await requestJson(upsertSkillsContract, {
|
||||
body: {
|
||||
skills: [{ name: s.name, description: s.description, content: s.content }],
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Created skill: ${s.name}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(variables.workspaceId),
|
||||
(prev) => {
|
||||
const byId = new Map((prev ?? []).map((skill) => [skill.id, skill]))
|
||||
for (const skill of data) byId.set(skill.id, skill)
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill mutation
|
||||
*/
|
||||
interface UpdateSkillParams {
|
||||
workspaceId: string
|
||||
skillId: string
|
||||
updates: {
|
||||
name?: string
|
||||
description?: string
|
||||
content?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, skillId, updates }: UpdateSkillParams) => {
|
||||
logger.info(`Updating skill: ${skillId} in workspace ${workspaceId}`)
|
||||
|
||||
const currentSkills = queryClient.getQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(workspaceId)
|
||||
)
|
||||
const currentSkill = currentSkills?.find((s) => s.id === skillId)
|
||||
|
||||
if (!currentSkill) {
|
||||
throw new Error('Skill not found')
|
||||
}
|
||||
|
||||
const { data } = await requestJson(upsertSkillsContract, {
|
||||
body: {
|
||||
skills: [
|
||||
{
|
||||
id: skillId,
|
||||
name: updates.name ?? currentSkill.name,
|
||||
description: updates.description ?? currentSkill.description,
|
||||
content: updates.content ?? currentSkill.content,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Updated skill: ${skillId}`)
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ workspaceId, skillId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
|
||||
|
||||
const previousSkills = queryClient.getQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
if (previousSkills) {
|
||||
queryClient.setQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(workspaceId),
|
||||
previousSkills.map((s) =>
|
||||
s.id === skillId
|
||||
? {
|
||||
...s,
|
||||
name: updates.name ?? s.name,
|
||||
description: updates.description ?? s.description,
|
||||
content: updates.content ?? s.content,
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousSkills }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousSkills) {
|
||||
queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete skill mutation
|
||||
*/
|
||||
interface DeleteSkillParams {
|
||||
workspaceId: string
|
||||
skillId: string
|
||||
}
|
||||
|
||||
export function useDeleteSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, skillId }: DeleteSkillParams) => {
|
||||
logger.info(`Deleting skill: ${skillId}`)
|
||||
|
||||
const data = await requestJson(deleteSkillContract, {
|
||||
query: { id: skillId, workspaceId },
|
||||
})
|
||||
|
||||
logger.info(`Deleted skill: ${skillId}`)
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ workspaceId, skillId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
|
||||
|
||||
const previousSkills = queryClient.getQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
if (previousSkills) {
|
||||
queryClient.setQueryData<SkillDefinition[]>(
|
||||
skillsKeys.list(workspaceId),
|
||||
previousSkills.filter((s) => s.id !== skillId)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousSkills }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousSkills) {
|
||||
queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
createBillingPortalContract,
|
||||
getInvoicesContract,
|
||||
getUserBillingContract,
|
||||
getUserUsageLimitContract,
|
||||
type InvoicesApiResponse,
|
||||
purchaseCreditsContract,
|
||||
type SubscriptionApiResponse,
|
||||
updateUsageLimitContract,
|
||||
} from '@/lib/api/contracts/subscription'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
export type { SubscriptionApiResponse }
|
||||
|
||||
export const SUBSCRIPTION_DATA_STALE_TIME = 5 * 60 * 1000
|
||||
export const USAGE_LIMIT_STALE_TIME = 30 * 1000
|
||||
export const INVOICES_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Query key factories for subscription-related queries
|
||||
*/
|
||||
export const subscriptionKeys = {
|
||||
all: ['subscription'] as const,
|
||||
users: () => [...subscriptionKeys.all, 'user'] as const,
|
||||
user: (includeOrg?: boolean) => [...subscriptionKeys.users(), { includeOrg }] as const,
|
||||
usage: () => [...subscriptionKeys.all, 'usage'] as const,
|
||||
invoicesAll: () => [...subscriptionKeys.all, 'invoices'] as const,
|
||||
invoices: (context: 'user' | 'organization' = 'user', organizationId?: string) =>
|
||||
[...subscriptionKeys.invoicesAll(), context, organizationId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user subscription data
|
||||
* @param includeOrg - Whether to include organization role data
|
||||
*/
|
||||
async function fetchSubscriptionData(
|
||||
includeOrg = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<SubscriptionApiResponse> {
|
||||
return requestJson(getUserBillingContract, {
|
||||
query: { context: 'user', includeOrg },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseSubscriptionDataOptions {
|
||||
/** Include organization membership and role data */
|
||||
includeOrg?: boolean
|
||||
/** Whether to enable the query (defaults to true) */
|
||||
enabled?: boolean
|
||||
/** Override default staleTime (defaults to 30s) */
|
||||
staleTime?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user subscription data
|
||||
* @param options - Optional configuration
|
||||
*/
|
||||
export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) {
|
||||
const { includeOrg = false, enabled = true, staleTime = SUBSCRIPTION_DATA_STALE_TIME } = options
|
||||
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.user(includeOrg),
|
||||
queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal),
|
||||
staleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch subscription data into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchSubscriptionData(queryClient: QueryClient) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: subscriptionKeys.user(false),
|
||||
queryFn: ({ signal }) => fetchSubscriptionData(false, signal),
|
||||
staleTime: SUBSCRIPTION_DATA_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch the billing queries the Upgrade page gates on: the
|
||||
* organization-scoped subscription variant (`includeOrg: true`, a different
|
||||
* cache key than the credits chip's `false` variant) and the usage-limit
|
||||
* metadata. Use on hover to warm both before navigating to `/upgrade`.
|
||||
*
|
||||
* Org-scoped subscribers additionally gate on the organization-billing query,
|
||||
* which is intentionally not prewarmed here: its key depends on the resolved
|
||||
* billing organization id, which is only derivable after the subscription and
|
||||
* workspace queries land, so it cannot be warmed at hover time.
|
||||
*/
|
||||
export function prefetchUpgradeBillingData(queryClient: QueryClient) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: subscriptionKeys.user(true),
|
||||
queryFn: ({ signal }) => fetchSubscriptionData(true, signal),
|
||||
staleTime: SUBSCRIPTION_DATA_STALE_TIME,
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: subscriptionKeys.usage(),
|
||||
queryFn: ({ signal }) => fetchUsageLimitData(signal),
|
||||
staleTime: USAGE_LIMIT_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user usage limit metadata
|
||||
* Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.)
|
||||
* For actual usage data (current, limit, percentUsed), use useSubscriptionData() instead
|
||||
*/
|
||||
async function fetchUsageLimitData(signal?: AbortSignal) {
|
||||
return requestJson(getUserUsageLimitContract, {
|
||||
query: { context: 'user' },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseUsageLimitDataOptions {
|
||||
/** Whether to enable the query (defaults to true) */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch usage limit metadata
|
||||
* Returns: currentLimit, minimumLimit, canEdit, plan, updatedAt
|
||||
* Use this for editing usage limits, not for displaying current usage
|
||||
*/
|
||||
export function useUsageLimitData(options: UseUsageLimitDataOptions = {}) {
|
||||
const { enabled = true } = options
|
||||
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.usage(),
|
||||
queryFn: ({ signal }) => fetchUsageLimitData(signal),
|
||||
staleTime: USAGE_LIMIT_STALE_TIME,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch finalized invoices for the active billing customer (personal or
|
||||
* organization-scoped).
|
||||
*/
|
||||
async function fetchInvoices(
|
||||
context: 'user' | 'organization',
|
||||
organizationId: string | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<InvoicesApiResponse> {
|
||||
return requestJson(getInvoicesContract, {
|
||||
query: { context, organizationId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseInvoicesOptions {
|
||||
/** Billing context to read invoices for (defaults to the personal customer). */
|
||||
context?: 'user' | 'organization'
|
||||
/** Required when `context` is `organization`. */
|
||||
organizationId?: string
|
||||
/** Whether to enable the query (defaults to true). */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch finalized Stripe invoices for the current billing customer.
|
||||
* Returns an empty list when there is no customer or Stripe is not configured.
|
||||
*/
|
||||
export function useInvoices(options: UseInvoicesOptions = {}) {
|
||||
const { context = 'user', organizationId, enabled = true } = options
|
||||
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.invoices(context, organizationId),
|
||||
queryFn: ({ signal }) => fetchInvoices(context, organizationId, signal),
|
||||
staleTime: INVOICES_STALE_TIME,
|
||||
enabled: enabled && (context !== 'organization' || Boolean(organizationId)),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage limit mutation
|
||||
*/
|
||||
interface UpdateUsageLimitParams {
|
||||
limit: ContractBodyInput<typeof updateUsageLimitContract>['limit']
|
||||
}
|
||||
|
||||
export function useUpdateUsageLimit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ limit }: UpdateUsageLimitParams) => {
|
||||
return requestJson(updateUsageLimitContract, {
|
||||
body: { context: 'user', limit },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ limit }) => {
|
||||
await queryClient.cancelQueries({ queryKey: subscriptionKeys.all })
|
||||
|
||||
const previousSubscriptionData = queryClient.getQueryData(subscriptionKeys.user(false))
|
||||
const previousSubscriptionDataWithOrg = queryClient.getQueryData(subscriptionKeys.user(true))
|
||||
const previousUsageData = queryClient.getQueryData(subscriptionKeys.usage())
|
||||
|
||||
const updateSubscriptionData = (old: SubscriptionApiResponse | undefined) => {
|
||||
if (!old) return old
|
||||
const currentUsage = old.data?.usage?.current || 0
|
||||
const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
|
||||
|
||||
return {
|
||||
...old,
|
||||
data: {
|
||||
...old.data,
|
||||
usage: {
|
||||
...old.data?.usage,
|
||||
limit,
|
||||
percentUsed: newPercentUsed,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueryData<SubscriptionApiResponse | undefined>(
|
||||
subscriptionKeys.user(false),
|
||||
updateSubscriptionData
|
||||
)
|
||||
queryClient.setQueryData<SubscriptionApiResponse | undefined>(
|
||||
subscriptionKeys.user(true),
|
||||
updateSubscriptionData
|
||||
)
|
||||
|
||||
queryClient.setQueryData<Awaited<ReturnType<typeof fetchUsageLimitData>> | undefined>(
|
||||
subscriptionKeys.usage(),
|
||||
(old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
data: {
|
||||
...old.data,
|
||||
currentLimit: limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { previousSubscriptionData, previousSubscriptionDataWithOrg, previousUsageData }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousSubscriptionData) {
|
||||
queryClient.setQueryData(subscriptionKeys.user(false), context.previousSubscriptionData)
|
||||
}
|
||||
if (context?.previousSubscriptionDataWithOrg) {
|
||||
queryClient.setQueryData(
|
||||
subscriptionKeys.user(true),
|
||||
context.previousSubscriptionDataWithOrg
|
||||
)
|
||||
}
|
||||
if (context?.previousUsageData) {
|
||||
queryClient.setQueryData(subscriptionKeys.usage(), context.previousUsageData)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade subscription mutation
|
||||
*/
|
||||
interface UpgradeSubscriptionParams {
|
||||
plan: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useUpgradeSubscription() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ plan }: UpgradeSubscriptionParams) => {
|
||||
return { plan }
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.invoicesAll() }),
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }),
|
||||
...(variables.orgId
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.orgId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.subscription(variables.orgId),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase credits mutation
|
||||
*/
|
||||
interface PurchaseCreditsParams {
|
||||
amount: ContractBodyInput<typeof purchaseCreditsContract>['amount']
|
||||
requestId: ContractBodyInput<typeof purchaseCreditsContract>['requestId']
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function usePurchaseCredits() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ amount, requestId }: PurchaseCreditsParams) => {
|
||||
return requestJson(purchaseCreditsContract, {
|
||||
body: { amount, requestId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
|
||||
...(variables.orgId
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.orgId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.subscription(variables.orgId),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open billing portal mutation
|
||||
*/
|
||||
type OpenBillingPortalParams = ContractBodyInput<typeof createBillingPortalContract>
|
||||
|
||||
export function useOpenBillingPortal() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: OpenBillingPortalParams) => {
|
||||
const data = await requestJson(createBillingPortalContract, {
|
||||
body,
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { queryClient, cacheStore } = vi.hoisted(() => {
|
||||
const cache = new Map<string, unknown>()
|
||||
return {
|
||||
cacheStore: cache,
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryData: vi.fn((key: readonly unknown[]) => cache.get(JSON.stringify(key))),
|
||||
setQueryData: vi.fn((key: readonly unknown[], updater: unknown) => {
|
||||
const k = JSON.stringify(key)
|
||||
const prev = cache.get(k)
|
||||
const next =
|
||||
typeof updater === 'function' ? (updater as (p: unknown) => unknown)(prev) : updater
|
||||
cache.set(k, next)
|
||||
return next
|
||||
}),
|
||||
getQueriesData: vi.fn((opts: { queryKey: readonly unknown[] }) => {
|
||||
const prefix = JSON.stringify(opts.queryKey).slice(0, -1)
|
||||
return [...cache.entries()]
|
||||
.filter(([k]) => k.startsWith(prefix))
|
||||
.map(([k, v]) => [JSON.parse(k), v])
|
||||
}),
|
||||
removeQueries: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: {},
|
||||
infiniteQueryOptions: (opts: unknown) => opts,
|
||||
useQuery: vi.fn(),
|
||||
useInfiniteQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(() => queryClient),
|
||||
useMutation: vi.fn((options) => options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/errors', () => ({
|
||||
isValidationError: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/contracts/tables', () => ({
|
||||
addTableColumnContract: {},
|
||||
addWorkflowGroupContract: {},
|
||||
batchCreateTableRowsContract: {},
|
||||
batchUpdateTableRowsContract: {},
|
||||
cancelTableRunsContract: {},
|
||||
createTableContract: {},
|
||||
createTableRowContract: {},
|
||||
deleteTableColumnContract: {},
|
||||
deleteTableContract: {},
|
||||
deleteTableRowContract: {},
|
||||
deleteTableRowsContract: {},
|
||||
deleteWorkflowGroupContract: {},
|
||||
getTableContract: {},
|
||||
importCsvContract: {},
|
||||
listTableRowsContract: {},
|
||||
listTablesContract: {},
|
||||
renameTableContract: {},
|
||||
restoreTableContract: {},
|
||||
runWorkflowGroupContract: {},
|
||||
updateTableColumnContract: {},
|
||||
updateTableMetadataContract: {},
|
||||
updateTableRowContract: {},
|
||||
updateWorkflowGroupContract: {},
|
||||
uploadCsvContract: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/workspace/providers/socket-provider', () => ({
|
||||
useSocket: vi.fn(() => ({ socket: null })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
|
||||
import {
|
||||
tableRowsInfiniteOptions,
|
||||
tableRowsParamsKey,
|
||||
useDeleteColumn,
|
||||
useRestoreTable,
|
||||
useUpdateColumn,
|
||||
} from '@/hooks/queries/tables'
|
||||
import { tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
|
||||
const TABLE_ID = 'tbl-1'
|
||||
const WORKSPACE_ID = 'ws-1'
|
||||
|
||||
function setCache(key: readonly unknown[], value: unknown) {
|
||||
cacheStore.set(JSON.stringify(key), value)
|
||||
}
|
||||
|
||||
function getCache<T>(key: readonly unknown[]): T | undefined {
|
||||
return cacheStore.get(JSON.stringify(key)) as T | undefined
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cacheStore.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useDeleteColumn optimistic update', () => {
|
||||
it('removes column from schema cache, strips its width, and clears it from row data', async () => {
|
||||
setCache(tableKeys.detail(TABLE_ID), {
|
||||
id: TABLE_ID,
|
||||
schema: {
|
||||
columns: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'age', type: 'number' },
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
columnWidths: { name: 200, age: 100 },
|
||||
},
|
||||
})
|
||||
setCache(tableKeys.rowsRoot(TABLE_ID), {
|
||||
rows: [
|
||||
{ id: 'r1', data: { name: 'a', age: 1 } },
|
||||
{ id: 'r2', data: { name: 'b', age: 2 } },
|
||||
],
|
||||
totalCount: 2,
|
||||
})
|
||||
|
||||
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
const ctx = await hook.onMutate?.('age')
|
||||
|
||||
const detail = getCache<{
|
||||
schema: { columns: Array<{ name: string }> }
|
||||
metadata: { columnWidths: Record<string, number> }
|
||||
}>(tableKeys.detail(TABLE_ID))
|
||||
expect(detail?.schema.columns.map((c) => c.name)).toEqual(['name'])
|
||||
expect(detail?.metadata.columnWidths).toEqual({ name: 200 })
|
||||
|
||||
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
|
||||
tableKeys.rowsRoot(TABLE_ID)
|
||||
)
|
||||
expect(rows?.rows.every((r) => !('age' in r.data))).toBe(true)
|
||||
expect(rows?.rows[0]?.data).toEqual({ name: 'a' })
|
||||
|
||||
expect(ctx?.previousDetail).toBeDefined()
|
||||
expect(ctx?.rowSnapshots?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rolls back schema and rows on error using snapshots', async () => {
|
||||
const originalDetail = {
|
||||
id: TABLE_ID,
|
||||
schema: { columns: [{ name: 'name' }, { name: 'age' }] },
|
||||
metadata: { columnWidths: { name: 200, age: 100 } },
|
||||
}
|
||||
const originalRows = {
|
||||
rows: [{ id: 'r1', data: { name: 'a', age: 1 } }],
|
||||
totalCount: 1,
|
||||
}
|
||||
setCache(tableKeys.detail(TABLE_ID), originalDetail)
|
||||
setCache(tableKeys.rowsRoot(TABLE_ID), originalRows)
|
||||
|
||||
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
const ctx = await hook.onMutate?.('age')
|
||||
|
||||
expect(getCache(tableKeys.detail(TABLE_ID))).not.toEqual(originalDetail)
|
||||
|
||||
hook.onError?.(new Error('boom'), 'age', ctx)
|
||||
|
||||
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(originalDetail)
|
||||
expect(getCache(tableKeys.rowsRoot(TABLE_ID))).toEqual(originalRows)
|
||||
})
|
||||
|
||||
it('invalidates schema, rows, and lists in onSettled', () => {
|
||||
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
hook.onSettled?.(undefined, null, 'age', undefined)
|
||||
|
||||
const calls = queryClient.invalidateQueries.mock.calls.map((c) => c[0]?.queryKey)
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
tableKeys.detail(TABLE_ID),
|
||||
tableKeys.rowsRoot(TABLE_ID),
|
||||
tableKeys.lists(),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateColumn optimistic update', () => {
|
||||
it('writes the column update to the schema cache and rolls back on error', async () => {
|
||||
const original = {
|
||||
id: TABLE_ID,
|
||||
schema: {
|
||||
columns: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'age', type: 'string' },
|
||||
],
|
||||
},
|
||||
}
|
||||
setCache(tableKeys.detail(TABLE_ID), original)
|
||||
|
||||
const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
const ctx = await hook.onMutate?.({ columnName: 'age', updates: { type: 'number' } })
|
||||
|
||||
const after = getCache<{ schema: { columns: Array<{ name: string; type: string }> } }>(
|
||||
tableKeys.detail(TABLE_ID)
|
||||
)
|
||||
expect(after?.schema.columns.find((c) => c.name === 'age')?.type).toBe('number')
|
||||
|
||||
hook.onError?.(new Error('boom'), { columnName: 'age', updates: { type: 'number' } }, ctx)
|
||||
|
||||
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(original)
|
||||
})
|
||||
|
||||
it('renames metadata-only: patches the column name + stamps id, leaves row data untouched', async () => {
|
||||
setCache(tableKeys.detail(TABLE_ID), {
|
||||
id: TABLE_ID,
|
||||
schema: { columns: [{ name: 'age', type: 'number' }] },
|
||||
})
|
||||
setCache(tableKeys.rowsRoot(TABLE_ID), {
|
||||
rows: [
|
||||
{ id: 'r1', data: { age: 30 } },
|
||||
{ id: 'r2', data: { age: 40 } },
|
||||
],
|
||||
totalCount: 2,
|
||||
})
|
||||
|
||||
const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
await hook.onMutate?.({ columnName: 'age', updates: { name: 'years' } })
|
||||
|
||||
// Row data is id-keyed; a rename never moves it. The stored key (`age`)
|
||||
// becomes the column's stamped id, so cells stay reachable via getColumnId.
|
||||
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
|
||||
tableKeys.rowsRoot(TABLE_ID)
|
||||
)
|
||||
expect(rows?.rows[0]?.data).toEqual({ age: 30 })
|
||||
expect(rows?.rows[1]?.data).toEqual({ age: 40 })
|
||||
|
||||
const detail = getCache<{ schema: { columns: Array<{ id?: string; name: string }> } }>(
|
||||
tableKeys.detail(TABLE_ID)
|
||||
)
|
||||
expect(detail?.schema.columns[0]).toMatchObject({ id: 'age', name: 'years' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRestoreTable cache invalidation', () => {
|
||||
it('primes the table detail cache and clears stale rows for the restored table', () => {
|
||||
const hook = useRestoreTable()
|
||||
const table = {
|
||||
id: TABLE_ID,
|
||||
name: 'Restored table',
|
||||
schema: { columns: [{ name: 'name', type: 'string' }] },
|
||||
rowCount: 1,
|
||||
maxRows: 100,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
createdBy: 'user-1',
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
hook.onSuccess?.({ success: true, data: { table } }, TABLE_ID, undefined)
|
||||
|
||||
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(table)
|
||||
expect(queryClient.removeQueries).toHaveBeenCalledWith({
|
||||
queryKey: tableKeys.rowsRoot(TABLE_ID),
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates lists, table detail, and row data for the restored table', () => {
|
||||
const hook = useRestoreTable()
|
||||
hook.onSettled?.(undefined, null, TABLE_ID, undefined)
|
||||
|
||||
const calls = queryClient.invalidateQueries.mock.calls.map((c) => c[0]?.queryKey)
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
tableKeys.lists(),
|
||||
tableKeys.detail(TABLE_ID),
|
||||
tableKeys.rowsRoot(TABLE_ID),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeleteColumn case-insensitive row cleanup', () => {
|
||||
it('strips the row data key even when stored casing differs from the requested name', async () => {
|
||||
setCache(tableKeys.detail(TABLE_ID), {
|
||||
id: TABLE_ID,
|
||||
schema: { columns: [{ name: 'Age', type: 'number' }] },
|
||||
})
|
||||
setCache(tableKeys.rowsRoot(TABLE_ID), {
|
||||
rows: [{ id: 'r1', data: { Age: 30, name: 'a' } }],
|
||||
totalCount: 1,
|
||||
})
|
||||
|
||||
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
await hook.onMutate?.('age')
|
||||
|
||||
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
|
||||
tableKeys.rowsRoot(TABLE_ID)
|
||||
)
|
||||
expect(rows?.rows[0]?.data).toEqual({ name: 'a' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tableRowsParamsKey', () => {
|
||||
it('produces the same key for identical params', () => {
|
||||
const k1 = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
const k2 = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
expect(k1).toBe(k2)
|
||||
})
|
||||
|
||||
it('treats undefined filter and sort as null', () => {
|
||||
const withUndefined = tableRowsParamsKey({ pageSize: 1000, filter: undefined, sort: undefined })
|
||||
const withNull = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
expect(withUndefined).toBe(withNull)
|
||||
})
|
||||
|
||||
it('produces different keys for different filters', () => {
|
||||
const k1 = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
const k2 = tableRowsParamsKey({
|
||||
pageSize: 1000,
|
||||
filter: { column: 'name', operator: 'eq', value: 'Alice' } as never,
|
||||
sort: null,
|
||||
})
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it('produces different keys for different page sizes', () => {
|
||||
const k1 = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
const k2 = tableRowsParamsKey({ pageSize: 500, filter: null, sort: null })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it('produces different keys for different sorts', () => {
|
||||
const k1 = tableRowsParamsKey({ pageSize: 1000, filter: null, sort: null })
|
||||
const k2 = tableRowsParamsKey({
|
||||
pageSize: 1000,
|
||||
filter: null,
|
||||
sort: { column: 'name', direction: 'asc' } as never,
|
||||
})
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tableRowsInfiniteOptions', () => {
|
||||
const PAGE_SIZE = 1000
|
||||
|
||||
interface PageFixture {
|
||||
rows: Array<{ id: string; orderKey?: string }>
|
||||
totalCount: number | null
|
||||
}
|
||||
|
||||
function makeOpts(pageSize = PAGE_SIZE, sort: unknown = null) {
|
||||
return tableRowsInfiniteOptions({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tableId: TABLE_ID,
|
||||
pageSize,
|
||||
filter: null,
|
||||
sort: sort as never,
|
||||
}) as {
|
||||
queryKey: readonly unknown[]
|
||||
getNextPageParam: (
|
||||
lastPage: PageFixture,
|
||||
allPages: PageFixture[],
|
||||
lastPageParam: unknown
|
||||
) => number | { orderKey: string; id: string } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
function makePage(count: number, totalCount: number | null, startAt = 0, withOrderKey = false) {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, i) => ({
|
||||
id: `r${startAt + i}`,
|
||||
...(withOrderKey ? { orderKey: `a${startAt + i}` } : {}),
|
||||
})),
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
function next(
|
||||
opts: ReturnType<typeof makeOpts>,
|
||||
pages: PageFixture[],
|
||||
lastPageParam: unknown = 0
|
||||
) {
|
||||
return opts.getNextPageParam(pages[pages.length - 1], pages, lastPageParam)
|
||||
}
|
||||
|
||||
it('getNextPageParam terminates when the count is covered by a partial page', () => {
|
||||
const opts = makeOpts()
|
||||
expect(next(opts, [makePage(500, 500)])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNextPageParam terminates on an empty page', () => {
|
||||
const opts = makeOpts()
|
||||
expect(next(opts, [makePage(1000, null), makePage(0, null, 1000)])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNextPageParam continues past a short page when the count says more rows exist', () => {
|
||||
// The regression the termination rule exists for: a page shorter than the
|
||||
// requested size (e.g. a byte-cut page) must not be read as end-of-table.
|
||||
const opts = makeOpts()
|
||||
expect(next(opts, [makePage(36, 100)])).toBe(36)
|
||||
})
|
||||
|
||||
it('getNextPageParam terminates a full page when the count is covered', () => {
|
||||
const opts = makeOpts()
|
||||
expect(next(opts, [makePage(PAGE_SIZE, PAGE_SIZE)])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNextPageParam returns next offset for a full page with an unknown count', () => {
|
||||
const opts = makeOpts()
|
||||
expect(next(opts, [makePage(PAGE_SIZE, null)])).toBe(PAGE_SIZE)
|
||||
})
|
||||
|
||||
it('getNextPageParam advances correctly across three pages', () => {
|
||||
const opts = makeOpts()
|
||||
const p0 = makePage(PAGE_SIZE, 2200)
|
||||
const p1 = makePage(PAGE_SIZE, null, 1000)
|
||||
const p2 = makePage(200, null, 2000)
|
||||
|
||||
expect(next(opts, [p0])).toBe(1000)
|
||||
expect(next(opts, [p0, p1], 1000)).toBe(2000)
|
||||
expect(next(opts, [p0, p1, p2], 2000)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNextPageParam returns a keyset cursor when rows carry orderKey and there is no sort', () => {
|
||||
const opts = makeOpts()
|
||||
const pages = [makePage(PAGE_SIZE, 2000, 0, true)]
|
||||
expect(next(opts, pages)).toEqual({
|
||||
orderKey: `a${PAGE_SIZE - 1}`,
|
||||
id: `r${PAGE_SIZE - 1}`,
|
||||
})
|
||||
})
|
||||
|
||||
it('getNextPageParam falls back to offset for sorted views even with orderKey present', () => {
|
||||
const opts = makeOpts(PAGE_SIZE, { column: 'name', direction: 'asc' })
|
||||
const p0 = makePage(PAGE_SIZE, 3000, 0, true)
|
||||
const p1 = makePage(PAGE_SIZE, null, 1000, true)
|
||||
expect(next(opts, [p0])).toBe(PAGE_SIZE)
|
||||
expect(next(opts, [p0, p1], PAGE_SIZE)).toBe(PAGE_SIZE * 2)
|
||||
})
|
||||
|
||||
it('queryKey includes the result of tableRowsParamsKey', () => {
|
||||
const paramsKey = tableRowsParamsKey({ pageSize: PAGE_SIZE, filter: null, sort: null })
|
||||
const opts = makeOpts(PAGE_SIZE)
|
||||
// queryKey is a tuple; one element must be exactly the paramsKey string
|
||||
expect(opts.queryKey).toContain(paramsKey)
|
||||
})
|
||||
|
||||
it('queryKey differs when filter changes', () => {
|
||||
const opts1 = tableRowsInfiniteOptions({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tableId: TABLE_ID,
|
||||
pageSize: PAGE_SIZE,
|
||||
filter: null,
|
||||
sort: null,
|
||||
}) as { queryKey: readonly unknown[] }
|
||||
const opts2 = tableRowsInfiniteOptions({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tableId: TABLE_ID,
|
||||
pageSize: PAGE_SIZE,
|
||||
filter: { column: 'name', operator: 'eq', value: 'Alice' } as never,
|
||||
sort: null,
|
||||
}) as { queryKey: readonly unknown[] }
|
||||
expect(JSON.stringify(opts1.queryKey)).not.toBe(JSON.stringify(opts2.queryKey))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson } = vi.hoisted(() => ({
|
||||
mockRequestJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}))
|
||||
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
|
||||
import {
|
||||
unsubscribeKeys,
|
||||
useUnsubscribe,
|
||||
useUnsubscribeMutation,
|
||||
} from '@/hooks/queries/unsubscribe'
|
||||
|
||||
const EMAIL = 'person@example.com'
|
||||
const TOKEN = 'tok-123'
|
||||
|
||||
const getResponse = {
|
||||
success: true as const,
|
||||
email: EMAIL,
|
||||
token: TOKEN,
|
||||
emailType: 'marketing',
|
||||
isTransactional: false,
|
||||
currentPreferences: {
|
||||
unsubscribeAll: false,
|
||||
unsubscribeMarketing: false,
|
||||
unsubscribeUpdates: false,
|
||||
unsubscribeNotifications: false,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`).
|
||||
* Mounts the hook in a real React 19 root under jsdom, wrapped in a real
|
||||
* `QueryClientProvider`, so query/mutation lifecycles run exactly as in the app.
|
||||
*/
|
||||
function renderHookWithClient<T>(useHook: () => T): {
|
||||
result: () => T
|
||||
queryClient: QueryClient
|
||||
unmount: () => void
|
||||
} {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
let latest: T
|
||||
|
||||
function Probe() {
|
||||
latest = useHook()
|
||||
return null
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Wrapper>
|
||||
<Probe />
|
||||
</Wrapper>
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
result: () => latest,
|
||||
queryClient,
|
||||
unmount: () => act(() => root.unmount()),
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush pending microtasks and the macrotask queue (query observer scheduling) inside act(). */
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve()
|
||||
await sleep(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useUnsubscribe', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('is disabled and does not fetch when email or token is missing', async () => {
|
||||
const missingToken = renderHookWithClient(() => useUnsubscribe(EMAIL, undefined))
|
||||
const missingEmail = renderHookWithClient(() => useUnsubscribe(undefined, TOKEN))
|
||||
const missingBoth = renderHookWithClient(() => useUnsubscribe(undefined, undefined))
|
||||
await flush()
|
||||
|
||||
expect(missingToken.result().fetchStatus).toBe('idle')
|
||||
expect(missingEmail.result().fetchStatus).toBe('idle')
|
||||
expect(missingBoth.result().fetchStatus).toBe('idle')
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
|
||||
missingToken.unmount()
|
||||
missingEmail.unmount()
|
||||
missingBoth.unmount()
|
||||
})
|
||||
|
||||
it('fetches when both params are present and surfaces the contract data', async () => {
|
||||
mockRequestJson.mockResolvedValueOnce(getResponse)
|
||||
|
||||
const { result, unmount } = renderHookWithClient(() => useUnsubscribe(EMAIL, TOKEN))
|
||||
await flush()
|
||||
|
||||
expect(requestJson).toHaveBeenCalledTimes(1)
|
||||
expect(requestJson).toHaveBeenCalledWith(
|
||||
unsubscribeGetContract,
|
||||
expect.objectContaining({ query: { email: EMAIL, token: TOKEN } })
|
||||
)
|
||||
expect(result().isSuccess).toBe(true)
|
||||
expect(result().data).toEqual(getResponse)
|
||||
expect(result().data?.isTransactional).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUnsubscribeMutation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calls requestJson with the post contract and flips the cached preference flag on success', async () => {
|
||||
mockRequestJson.mockResolvedValueOnce({
|
||||
success: true as const,
|
||||
message: 'Unsubscribed',
|
||||
email: EMAIL,
|
||||
type: 'marketing' as const,
|
||||
emailType: 'marketing',
|
||||
})
|
||||
|
||||
const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
|
||||
const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
|
||||
queryClient.setQueryData(detailKey, getResponse)
|
||||
|
||||
await act(async () => {
|
||||
await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'marketing' })
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(result().isSuccess).toBe(true)
|
||||
expect(requestJson).toHaveBeenCalledTimes(1)
|
||||
expect(requestJson).toHaveBeenCalledWith(
|
||||
unsubscribePostContract,
|
||||
expect.objectContaining({ body: { email: EMAIL, token: TOKEN, type: 'marketing' } })
|
||||
)
|
||||
|
||||
const reconciled = queryClient.getQueryData<typeof getResponse>(detailKey)
|
||||
expect(reconciled?.currentPreferences.unsubscribeMarketing).toBe(true)
|
||||
expect(reconciled?.currentPreferences.unsubscribeAll).toBe(false)
|
||||
expect(reconciled?.currentPreferences.unsubscribeUpdates).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('flips unsubscribeAll when type is "all"', async () => {
|
||||
mockRequestJson.mockResolvedValueOnce({
|
||||
success: true as const,
|
||||
message: 'Unsubscribed',
|
||||
email: EMAIL,
|
||||
type: 'all' as const,
|
||||
emailType: 'marketing',
|
||||
})
|
||||
|
||||
const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
|
||||
const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
|
||||
queryClient.setQueryData(detailKey, getResponse)
|
||||
|
||||
await act(async () => {
|
||||
await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'all' })
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(result().isSuccess).toBe(true)
|
||||
const reconciled = queryClient.getQueryData<typeof getResponse>(detailKey)
|
||||
expect(reconciled?.currentPreferences.unsubscribeAll).toBe(true)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type UnsubscribeActionResponse,
|
||||
type UnsubscribeData,
|
||||
type UnsubscribeType,
|
||||
unsubscribeGetContract,
|
||||
unsubscribePostContract,
|
||||
} from '@/lib/api/contracts/user'
|
||||
|
||||
export const UNSUBSCRIBE_DETAIL_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const unsubscribeKeys = {
|
||||
all: ['unsubscribe'] as const,
|
||||
details: () => [...unsubscribeKeys.all, 'detail'] as const,
|
||||
detail: (email?: string, token?: string) =>
|
||||
[...unsubscribeKeys.details(), email ?? '', token ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchUnsubscribe(
|
||||
email: string,
|
||||
token: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<UnsubscribeData> {
|
||||
return requestJson(unsubscribeGetContract, { query: { email, token }, signal })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an unsubscribe link and loads the recipient's current email preferences.
|
||||
* Auto-runs on mount once both `email` and `token` are present.
|
||||
*/
|
||||
export function useUnsubscribe(email?: string, token?: string) {
|
||||
return useQuery({
|
||||
queryKey: unsubscribeKeys.detail(email, token),
|
||||
queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal),
|
||||
enabled: Boolean(email) && Boolean(token),
|
||||
staleTime: UNSUBSCRIBE_DETAIL_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
interface UnsubscribeVariables {
|
||||
email: string
|
||||
token: string
|
||||
type: UnsubscribeType
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits an unsubscribe action and reconciles the cached preferences so the
|
||||
* affected option immediately reflects the unsubscribed state.
|
||||
*/
|
||||
export function useUnsubscribeMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<UnsubscribeActionResponse, Error, UnsubscribeVariables>({
|
||||
mutationFn: ({ email, token, type }) =>
|
||||
requestJson(unsubscribePostContract, { body: { email, token, type } }),
|
||||
onSuccess: (_data, { email, token, type }) => {
|
||||
const key = unsubscribeKeys.detail(email, token)
|
||||
queryClient.setQueryData<UnsubscribeData>(key, (previous) => {
|
||||
if (!previous) return previous
|
||||
const preferenceKey =
|
||||
type === 'all'
|
||||
? 'unsubscribeAll'
|
||||
: (`unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications')
|
||||
return {
|
||||
...previous,
|
||||
currentPreferences: {
|
||||
...previous.currentPreferences,
|
||||
[preferenceKey]: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getUsageLogsContract,
|
||||
type UsageLogPeriod,
|
||||
type UsageLogsApiResponse,
|
||||
} from '@/lib/api/contracts/user'
|
||||
import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export const USAGE_LOGS_LIST_STALE_TIME = 30 * 1000
|
||||
export const USAGE_SUMMARY_STALE_TIME = 30 * 1000
|
||||
|
||||
interface UsagePeriodFilter {
|
||||
period: UsageLogPeriod
|
||||
/** Required when `period` is `'custom'`. */
|
||||
startDate?: string
|
||||
/** Required when `period` is `'custom'`. */
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
async function fetchUsageLogs(
|
||||
filter: UsagePeriodFilter,
|
||||
limit: number,
|
||||
cursor: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
includeCredits = true
|
||||
): Promise<UsageLogsApiResponse> {
|
||||
return requestJson(getUsageLogsContract, {
|
||||
query: { ...filter, limit, cursor, includeCredits },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseUsageLogsOptions extends UsagePeriodFilter {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite-scrolls the authenticated user's credit-consuming usage events for
|
||||
* the Credit usage page, keyset-paginated by the backend's opaque
|
||||
* `nextCursor`. Keeps the prior filter's rows on screen while a newly
|
||||
* selected period/range loads, since the filter is a variable key.
|
||||
*/
|
||||
export function useUsageLogs({ period, startDate, endDate, enabled = true }: UseUsageLogsOptions) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: usageLogKeys.list(period, undefined, { startDate, endDate }),
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
fetchUsageLogs({ period, startDate, endDate }, PAGE_SIZE, pageParam, signal),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined,
|
||||
enabled,
|
||||
staleTime: USAGE_LOGS_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches just the total-credits summary for a fixed period — the compact
|
||||
* Billing settings glance doesn't need the paginated row list, so this skips
|
||||
* the infinite-query machinery and asks the backend for a single minimal page.
|
||||
*/
|
||||
export function useUsageSummary(period: Exclude<UsageLogPeriod, 'custom'>) {
|
||||
return useQuery({
|
||||
queryKey: usageLogKeys.summary(period),
|
||||
queryFn: ({ signal }) => fetchUsageLogs({ period }, 1, undefined, signal, false),
|
||||
staleTime: USAGE_SUMMARY_STALE_TIME,
|
||||
select: (data) => data.summary.totalCredits,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ForgetPasswordBody,
|
||||
forgetPasswordContract,
|
||||
getUserProfileContract,
|
||||
type UpdateUserProfileBody,
|
||||
type UserProfileApiUser,
|
||||
updateUserProfileContract,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('UserProfileQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for user profile
|
||||
*/
|
||||
export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const userProfileKeys = {
|
||||
all: ['userProfile'] as const,
|
||||
profile: () => [...userProfileKeys.all, 'profile'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile type, derived from the contract response shape minus
|
||||
* the auth-only `emailVerified` field which is not displayed in the UI.
|
||||
*/
|
||||
export type UserProfile = Omit<UserProfileApiUser, 'emailVerified'>
|
||||
|
||||
/**
|
||||
* Map raw API response user object to UserProfile.
|
||||
* Shared by both client fetch and server prefetch to prevent shape drift.
|
||||
*/
|
||||
export function mapUserProfileResponse(user: UserProfileApiUser): UserProfile {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile from API
|
||||
*/
|
||||
async function fetchUserProfile(signal?: AbortSignal): Promise<UserProfile> {
|
||||
const { user } = await requestJson(getUserProfileContract, { signal })
|
||||
return mapUserProfileResponse(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user profile
|
||||
*/
|
||||
export function useUserProfile() {
|
||||
return useQuery({
|
||||
queryKey: userProfileKeys.profile(),
|
||||
queryFn: ({ signal }) => fetchUserProfile(signal),
|
||||
staleTime: USER_PROFILE_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile mutation
|
||||
*/
|
||||
type UpdateProfileParams = UpdateUserProfileBody
|
||||
|
||||
export function useUpdateUserProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: UpdateProfileParams) => {
|
||||
return requestJson(updateUserProfileContract, { body: updates })
|
||||
},
|
||||
onMutate: async (updates) => {
|
||||
await queryClient.cancelQueries({ queryKey: userProfileKeys.profile() })
|
||||
|
||||
const previousProfile = queryClient.getQueryData<UserProfile>(userProfileKeys.profile())
|
||||
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData<UserProfile>(userProfileKeys.profile(), {
|
||||
...previousProfile,
|
||||
...updates,
|
||||
})
|
||||
}
|
||||
|
||||
return { previousProfile }
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProfile) {
|
||||
queryClient.setQueryData(userProfileKeys.profile(), context.previousProfile)
|
||||
}
|
||||
logger.error('Failed to update profile:', err)
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({ queryKey: userProfileKeys.profile() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password mutation
|
||||
*/
|
||||
type ResetPasswordParams = Pick<ForgetPasswordBody, 'email' | 'redirectTo'> & {
|
||||
redirectTo: string
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ email, redirectTo }: ResetPasswordParams) => {
|
||||
return requestJson(forgetPasswordContract, { body: { email, redirectTo } })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* React Query key factory for workspace credentials.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./folder-keys.ts} — so server-evaluated code (block
|
||||
* definitions, server prefetch) can import it without pulling client-reference
|
||||
* stubs from the `'use client'` `@/hooks/queries/credentials` module.
|
||||
*/
|
||||
export const workspaceCredentialKeys = {
|
||||
all: ['workspaceCredentials'] as const,
|
||||
lists: () => [...workspaceCredentialKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, type?: string, providerId?: string) =>
|
||||
[
|
||||
...workspaceCredentialKeys.lists(),
|
||||
workspaceId ?? 'none',
|
||||
type ?? 'all',
|
||||
providerId ?? 'all',
|
||||
] as const,
|
||||
details: () => [...workspaceCredentialKeys.all, 'detail'] as const,
|
||||
detail: (credentialId?: string) =>
|
||||
[...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const,
|
||||
members: (credentialId?: string) =>
|
||||
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const customToolsKeys = {
|
||||
all: ['customTools'] as const,
|
||||
lists: () => [...customToolsKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const,
|
||||
details: () => [...customToolsKeys.all, 'detail'] as const,
|
||||
detail: (toolId: string) => [...customToolsKeys.details(), toolId] as const,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getDeploymentVersionStateContract } from '@/lib/api/contracts/deployments'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Fetches the deployed state for a specific deployment version.
|
||||
*/
|
||||
export async function fetchDeploymentVersionState(
|
||||
workflowId: string,
|
||||
version: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowState> {
|
||||
const data = await requestJson(getDeploymentVersionStateContract, {
|
||||
params: { id: workflowId, version },
|
||||
signal,
|
||||
})
|
||||
if (!data.deployedState) {
|
||||
throw new Error('No deployed state returned')
|
||||
}
|
||||
|
||||
return data.deployedState
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson } = vi.hoisted(() => ({
|
||||
mockRequestJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/contracts/workflows', () => ({
|
||||
getWorkflowStateContract: { __contract: 'getWorkflowState' },
|
||||
}))
|
||||
|
||||
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
|
||||
|
||||
describe('fetchWorkflowEnvelope', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns the unwrapped envelope from the contract response', async () => {
|
||||
const envelope = {
|
||||
id: 'wf-1',
|
||||
isDeployed: true,
|
||||
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
||||
}
|
||||
mockRequestJson.mockResolvedValue({ data: envelope })
|
||||
|
||||
const result = await fetchWorkflowEnvelope('wf-1')
|
||||
|
||||
expect(result).toBe(envelope)
|
||||
})
|
||||
|
||||
it('forwards params.id and signal to requestJson against the contract', async () => {
|
||||
mockRequestJson.mockResolvedValue({ data: { id: 'wf-2' } })
|
||||
const controller = new AbortController()
|
||||
|
||||
await fetchWorkflowEnvelope('wf-2', controller.signal)
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledTimes(1)
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
{ __contract: 'getWorkflowState' },
|
||||
{ params: { id: 'wf-2' }, signal: controller.signal }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type GetWorkflowResponseData,
|
||||
getWorkflowStateContract,
|
||||
} from '@/lib/api/contracts/workflows'
|
||||
|
||||
/**
|
||||
* Fetches the full workflow envelope (in-state slice, deployment status,
|
||||
* variables, and row metadata) for a single workflow from GET
|
||||
* `/api/workflows/[id]`.
|
||||
*
|
||||
* Single source of truth for the `workflowKeys.state(id)` cache entry: the
|
||||
* registry store hydrates it via `fetchQuery` (always-fresh, in-flight
|
||||
* deduped) and `useWorkflowState`/`useWorkflowStates` project the mapped
|
||||
* `WorkflowState` out of the same entry with `select`, so this endpoint has
|
||||
* exactly one cache entry across the store and the hooks.
|
||||
*
|
||||
* Lives in a standalone util (rather than `hooks/queries/workflows.ts`) so the
|
||||
* registry store can import it without creating a store ↔ query-hook import
|
||||
* cycle.
|
||||
*/
|
||||
export async function fetchWorkflowEnvelope(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GetWorkflowResponseData> {
|
||||
const { data } = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* Fetches the workspace credential list.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module so block definitions and
|
||||
* server prefetch can import it without pulling client-reference stubs from the
|
||||
* `'use client'` `@/hooks/queries/credentials` module.
|
||||
*/
|
||||
export async function fetchWorkspaceCredentialList(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceCredential[]> {
|
||||
const data = await requestJson(listWorkspaceCredentialsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
export function getFolders(workspaceId: string): WorkflowFolder[] {
|
||||
return (
|
||||
getQueryClient().getQueryData<WorkflowFolder[]>(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS
|
||||
)
|
||||
}
|
||||
|
||||
export function getFolderMap(workspaceId: string): Record<string, WorkflowFolder> {
|
||||
return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder]))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type FolderQueryScope = 'active' | 'archived'
|
||||
|
||||
export const folderKeys = {
|
||||
all: ['folders'] as const,
|
||||
lists: () => [...folderKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, scope: FolderQueryScope = 'active') =>
|
||||
[...folderKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findLockedAncestorFolder,
|
||||
getFolderPath,
|
||||
isFolderEffectivelyLocked,
|
||||
isFolderOrAncestorLocked,
|
||||
isWorkflowEffectivelyLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
function makeFolder(overrides: Partial<WorkflowFolder> & { id: string }): WorkflowFolder {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name ?? overrides.id,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: overrides.parentId ?? null,
|
||||
color: '#000000',
|
||||
isExpanded: false,
|
||||
locked: overrides.locked ?? false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
archivedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('isFolderOrAncestorLocked', () => {
|
||||
it('returns false for root', () => {
|
||||
expect(isFolderOrAncestorLocked(null, {})).toBe(false)
|
||||
expect(isFolderOrAncestorLocked(undefined, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', locked: true }) }
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', locked: true }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
f3: makeFolder({ id: 'f3', parentId: 'f2' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f3', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f2', folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFolderPath', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(getFolderPath(null, {})).toBeNull()
|
||||
expect(getFolderPath(undefined, {})).toBeNull()
|
||||
expect(getFolderPath('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the leaf folder name when at top level', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering' }) }
|
||||
expect(getFolderPath('f1', folders)).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('joins ancestor names from root to leaf', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
api: makeFolder({ id: 'api', name: 'API', parentId: 'be' }),
|
||||
}
|
||||
expect(getFolderPath('api', folders)).toBe('Engineering / Backend / API')
|
||||
})
|
||||
|
||||
it('respects custom separators', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders, ' > ')).toBe('Engineering > Backend')
|
||||
})
|
||||
|
||||
it('returns the partial path resolved before a missing ancestor', () => {
|
||||
const folders = {
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'missing' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders)).toBe('Backend')
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(getFolderPath('f1', folders)).toBe('B / A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLockedAncestorFolder', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(findLockedAncestorFolder(null, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder(undefined, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the folder itself when it is the locked one', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering', locked: true }) }
|
||||
expect(findLockedAncestorFolder('f1', folders)?.name).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('returns the closest locked ancestor, not the root', () => {
|
||||
const folders = {
|
||||
root: makeFolder({ id: 'root', name: 'Root', locked: true }),
|
||||
mid: makeFolder({ id: 'mid', name: 'Mid', parentId: 'root', locked: true }),
|
||||
leaf: makeFolder({ id: 'leaf', name: 'Leaf', parentId: 'mid' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('leaf', folders)?.id).toBe('mid')
|
||||
})
|
||||
|
||||
it('returns null when no folder in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f2', folders)).toBeNull()
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f1', folders)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowEffectivelyLocked', () => {
|
||||
it('treats undefined or null workflows as unlocked', () => {
|
||||
expect(isWorkflowEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isWorkflowEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the workflow row itself is locked', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: true, folderId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'be' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither row nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a workflow at workspace root with no row lock', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFolderEffectivelyLocked', () => {
|
||||
it('treats undefined or null folders as unlocked', () => {
|
||||
expect(isFolderEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isFolderEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: true, parentId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither the folder nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a root-level folder with no own lock', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
/**
|
||||
* Returns true when the folder or one of its ancestors is locked. Used to
|
||||
* mirror server-side cascading folder lock policy on the client without an
|
||||
* extra round-trip.
|
||||
*/
|
||||
export function isFolderOrAncestorLocked(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId = folderId ?? null
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return false
|
||||
visited.add(currentFolderId)
|
||||
|
||||
const folder = folders[currentFolderId]
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable path for a folder, e.g. `'Engineering / Backend'`.
|
||||
* Returns `null` when the folder is at workspace root or unknown. Cycles or
|
||||
* missing ancestors short-circuit by returning the segments resolved so far.
|
||||
*/
|
||||
export function getFolderPath(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
separator = ' / '
|
||||
): string | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const segments: string[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) break
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) break
|
||||
segments.unshift(folder.name)
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments.join(separator) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the closest locked ancestor folder for the given folderId, or `null`
|
||||
* when neither the folder nor any of its ancestors are locked. Cycles or
|
||||
* missing ancestors short-circuit and return `null` rather than looping.
|
||||
*/
|
||||
export function findLockedAncestorFolder(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): WorkflowFolder | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return null
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) return null
|
||||
if (folder.locked) return folder
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a workflow as visible to the client. Mirrors
|
||||
* the server's `getWorkflowLockStatus(workflowId)` (in `@sim/platform-authz/workflow`)
|
||||
* but reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined workflow as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isWorkflowEffectivelyLocked(
|
||||
workflow: { locked?: boolean | null; folderId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!workflow) return false
|
||||
if (workflow.locked) return true
|
||||
return isFolderOrAncestorLocked(workflow.folderId, folders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a folder as visible to the client. Mirrors the
|
||||
* server's `getFolderLockStatus(folderId)` (in `@sim/platform-authz/workflow`) but
|
||||
* reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined folder as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isFolderEffectivelyLocked(
|
||||
folder: { locked?: boolean | null; parentId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
return isFolderOrAncestorLocked(folder.parentId, folders)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
|
||||
describe('invalidateWorkflowLists', () => {
|
||||
it('invalidates scoped workflow lists and workflow selector caches', async () => {
|
||||
const queryClient = {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
await invalidateWorkflowLists(queryClient as any, 'ws-1', ['active', 'archived'])
|
||||
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, {
|
||||
queryKey: ['workflows', 'list', 'ws-1', 'active'],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, {
|
||||
queryKey: ['workflows', 'list', 'ws-1', 'archived'],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, {
|
||||
queryKey: ['selectors', 'sim.workflows', 'ws-1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { selectorKeys } from '@/hooks/selectors/query-keys'
|
||||
|
||||
export async function invalidateWorkflowSelectors(queryClient: QueryClient, workspaceId: string) {
|
||||
await queryClient.invalidateQueries({ queryKey: selectorKeys.simWorkflowsPrefix(workspaceId) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates workflow list consumers for a single workspace.
|
||||
*/
|
||||
export async function invalidateWorkflowLists(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string,
|
||||
scopes: WorkflowQueryScope[] = ['active']
|
||||
) {
|
||||
const uniqueScopes = [...new Set(scopes)]
|
||||
|
||||
await Promise.all([
|
||||
...uniqueScopes.map((scope) =>
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId, scope) })
|
||||
),
|
||||
invalidateWorkflowSelectors(queryClient, workspaceId),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const logger = createLogger('OptimisticMutation')
|
||||
|
||||
export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
|
||||
name: string
|
||||
getQueryKey: (variables: TVariables) => readonly unknown[]
|
||||
getSnapshot: (variables: TVariables) => Record<string, TItem>
|
||||
generateTempId: (variables: TVariables) => string
|
||||
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
|
||||
applyOptimisticUpdate: (tempId: string, item: TItem) => void
|
||||
replaceOptimisticEntry: (tempId: string, data: TData) => void
|
||||
rollback: (snapshot: Record<string, TItem>, variables: TVariables) => void
|
||||
onSuccessExtra?: (data: TData, variables: TVariables) => void
|
||||
}
|
||||
|
||||
export interface OptimisticMutationContext<TItem> {
|
||||
tempId: string
|
||||
previousState: Record<string, TItem>
|
||||
}
|
||||
|
||||
export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
|
||||
queryClient: QueryClient,
|
||||
config: OptimisticMutationConfig<TData, TVariables, TItem, OptimisticMutationContext<TItem>>
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
getQueryKey,
|
||||
getSnapshot,
|
||||
generateTempId,
|
||||
createOptimisticItem,
|
||||
applyOptimisticUpdate,
|
||||
replaceOptimisticEntry,
|
||||
rollback,
|
||||
onSuccessExtra,
|
||||
} = config
|
||||
|
||||
return {
|
||||
onMutate: async (variables: TVariables): Promise<OptimisticMutationContext<TItem>> => {
|
||||
const queryKey = getQueryKey(variables)
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousState = getSnapshot(variables)
|
||||
const tempId = generateTempId(variables)
|
||||
const optimisticItem = createOptimisticItem(variables, tempId)
|
||||
applyOptimisticUpdate(tempId, optimisticItem)
|
||||
logger.info(`[${name}] Added optimistic entry: ${tempId}`)
|
||||
return { tempId, previousState }
|
||||
},
|
||||
|
||||
onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext<TItem>) => {
|
||||
logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`)
|
||||
replaceOptimisticEntry(context.tempId, data)
|
||||
onSuccessExtra?.(data, variables)
|
||||
},
|
||||
|
||||
onError: (
|
||||
error: Error,
|
||||
_variables: TVariables,
|
||||
context: OptimisticMutationContext<TItem> | undefined
|
||||
) => {
|
||||
logger.error(`[${name}] Failed:`, error)
|
||||
if (context?.previousState) {
|
||||
rollback(context.previousState, _variables)
|
||||
logger.info(`[${name}] Rolled back to previous state`)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => {
|
||||
return queryClient.invalidateQueries({ queryKey: getQueryKey(variables) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTempId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* React Query key factory for user-defined tables.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./folder-keys.ts} — so it can be imported from server
|
||||
* components (e.g. the tables page prefetch) without pulling in the
|
||||
* `'use client'` `@/hooks/queries/tables` module, whose exports would
|
||||
* otherwise resolve to client-reference stubs on the server.
|
||||
*/
|
||||
|
||||
export type TableQueryScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export const TABLE_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export const tableKeys = {
|
||||
all: ['tables'] as const,
|
||||
lists: () => [...tableKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, scope: TableQueryScope = 'active') =>
|
||||
[...tableKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
details: () => [...tableKeys.all, 'detail'] as const,
|
||||
detail: (tableId: string) => [...tableKeys.details(), tableId] as const,
|
||||
exportJobs: (workspaceId?: string) =>
|
||||
[...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const,
|
||||
rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const,
|
||||
infiniteRows: (tableId: string, paramsKey: string) =>
|
||||
[...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const,
|
||||
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
|
||||
find: (tableId: string, paramsKey: string) =>
|
||||
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
|
||||
activeDispatches: (tableId: string) =>
|
||||
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
|
||||
enrichmentDetails: (tableId: string) =>
|
||||
[...tableKeys.detail(tableId), 'enrichment-detail'] as const,
|
||||
enrichmentDetail: (tableId: string, rowId: string, groupId: string) =>
|
||||
[...tableKeys.enrichmentDetails(tableId), rowId, groupId] as const,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
countLoadedTableRows,
|
||||
getNextTableRowsPageParam,
|
||||
hasMoreTableRows,
|
||||
} from '@/hooks/queries/utils/table-rows-pagination'
|
||||
|
||||
function makePage(count: number, totalCount: number | null, startAt = 0, withOrderKey = true) {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, i) => ({
|
||||
id: `r${startAt + i}`,
|
||||
...(withOrderKey ? { orderKey: `k${String(startAt + i).padStart(6, '0')}` } : {}),
|
||||
})),
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
describe('countLoadedTableRows', () => {
|
||||
it('sums rows across pages', () => {
|
||||
expect(countLoadedTableRows([])).toBe(0)
|
||||
expect(countLoadedTableRows([makePage(3, 10), makePage(2, null, 3)])).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMoreTableRows', () => {
|
||||
it('returns false with no pages', () => {
|
||||
expect(hasMoreTableRows([])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the last page is empty', () => {
|
||||
expect(hasMoreTableRows([makePage(1000, null), makePage(0, null, 1000)])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the page-0 count is covered', () => {
|
||||
expect(hasMoreTableRows([makePage(3, 3)])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a short page when the count says more exist', () => {
|
||||
// The regression this module exists for: a page shorter than the requested
|
||||
// size must never be read as end-of-table on its own.
|
||||
expect(hasMoreTableRows([makePage(36, 100)])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when the count is unknown and the last page is non-empty', () => {
|
||||
expect(hasMoreTableRows([makePage(1000, null)])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when a stale-low count is already exceeded', () => {
|
||||
expect(hasMoreTableRows([makePage(10, 5)])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextTableRowsPageParam', () => {
|
||||
it('returns undefined when no more rows exist', () => {
|
||||
expect(getNextTableRowsPageParam([makePage(3, 3)], false)).toBeUndefined()
|
||||
expect(getNextTableRowsPageParam([makePage(1000, null), makePage(0, null)], false)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the keyset cursor of the last loaded row on the default order', () => {
|
||||
const pages = [makePage(1000, 2000), makePage(500, null, 1000)]
|
||||
expect(getNextTableRowsPageParam(pages, false)).toEqual({
|
||||
orderKey: 'k001499',
|
||||
id: 'r1499',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the loaded-row offset for sorted views, even after short pages', () => {
|
||||
const pages = [makePage(1000, 2000), makePage(36, null, 1000)]
|
||||
expect(getNextTableRowsPageParam(pages, true)).toBe(1036)
|
||||
})
|
||||
|
||||
it('falls back to the loaded-row offset when the last row has no order key', () => {
|
||||
const pages = [makePage(700, 2000, 0, false)]
|
||||
expect(getNextTableRowsPageParam(pages, false)).toBe(700)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TableRowsCursor } from '@/lib/table/types'
|
||||
|
||||
/**
|
||||
* Infinite-rows page param: a keyset cursor on the default `(order_key, id)` order, or a numeric
|
||||
* offset for sorted views / legacy rows without an order key. `0` doubles as the first page.
|
||||
*/
|
||||
export type TableRowsPageParam = number | TableRowsCursor
|
||||
|
||||
interface TableRowsPageLike {
|
||||
rows: ReadonlyArray<{ id: string; orderKey?: string }>
|
||||
totalCount: number | null
|
||||
}
|
||||
|
||||
/** Rows loaded across all fetched pages. */
|
||||
export function countLoadedTableRows(pages: readonly TableRowsPageLike[]): number {
|
||||
return pages.reduce((sum, page) => sum + page.rows.length, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether more rows may exist past the fetched pages. A page is terminal only when it is
|
||||
* empty or when page 0's `COUNT(*)` is already covered — never when it is merely shorter
|
||||
* than the requested page size, so a short server page can never be misread as end-of-table.
|
||||
*
|
||||
* `totalCount` is advisory (computed in a separate transaction from the page read). A
|
||||
* stale-high count self-corrects via the empty-page rule at the cost of one extra request;
|
||||
* a stale-low count (rows deleted after page 0's COUNT) stops the drain early — accepted,
|
||||
* since the view is already stale and the run-stream/interval invalidations refetch it.
|
||||
*/
|
||||
export function hasMoreTableRows(pages: readonly TableRowsPageLike[]): boolean {
|
||||
const lastPage = pages[pages.length - 1]
|
||||
if (!lastPage || lastPage.rows.length === 0) return false
|
||||
const totalCount = pages[0].totalCount
|
||||
return totalCount == null || countLoadedTableRows(pages) < totalCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation for the next page: a keyset cursor from the last loaded row on the default
|
||||
* order, else the absolute offset — the actual loaded-row count, not pages × pageSize, so
|
||||
* short pages resume without gaps.
|
||||
*/
|
||||
export function getNextTableRowsPageParam(
|
||||
pages: readonly TableRowsPageLike[],
|
||||
sorted: boolean
|
||||
): TableRowsPageParam | undefined {
|
||||
if (!hasMoreTableRows(pages)) return undefined
|
||||
const lastPage = pages[pages.length - 1]
|
||||
if (!sorted) {
|
||||
const last = lastPage.rows[lastPage.rows.length - 1]
|
||||
if (last?.orderKey) return { orderKey: last.orderKey, id: last.id }
|
||||
}
|
||||
return countLoadedTableRows(pages)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
interface SortableWorkflow {
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface SortableFolder {
|
||||
workspaceId?: string
|
||||
parentId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the insertion sort order that places a new item at the top of a
|
||||
* mixed list of folders and workflows within the same parent scope.
|
||||
*/
|
||||
export function getTopInsertionSortOrder(
|
||||
workflows: Record<string, SortableWorkflow>,
|
||||
folders: Record<string, SortableFolder>,
|
||||
workspaceId: string,
|
||||
parentId: string | null | undefined
|
||||
): number {
|
||||
const normalizedParentId = parentId ?? null
|
||||
|
||||
const siblingWorkflows = Object.values(workflows).filter(
|
||||
(workflow) =>
|
||||
workflow.workspaceId === workspaceId && (workflow.folderId ?? null) === normalizedParentId
|
||||
)
|
||||
const siblingFolders = Object.values(folders).filter(
|
||||
(folder) =>
|
||||
folder.workspaceId === workspaceId && (folder.parentId ?? null) === normalizedParentId
|
||||
)
|
||||
|
||||
const siblingOrders = [
|
||||
...siblingWorkflows.map((workflow) => workflow.sortOrder ?? 0),
|
||||
...siblingFolders.map((folder) => folder.sortOrder),
|
||||
]
|
||||
|
||||
if (siblingOrders.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(...siblingOrders) - 1
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* React Query key factory for the credit usage log.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./table-keys.ts} — so it can be imported from server
|
||||
* components without pulling in the `'use client'`
|
||||
* `@/hooks/queries/usage-logs` module, whose exports would otherwise
|
||||
* resolve to client-reference stubs on the server.
|
||||
*/
|
||||
|
||||
import type { UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
|
||||
|
||||
export interface UsageLogDateRange {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export const usageLogKeys = {
|
||||
all: ['usage-logs'] as const,
|
||||
lists: () => [...usageLogKeys.all, 'list'] as const,
|
||||
list: (period: UsageLogPeriod, source?: UsageLogSource, dateRange?: UsageLogDateRange) =>
|
||||
[
|
||||
...usageLogKeys.lists(),
|
||||
period,
|
||||
source ?? '',
|
||||
dateRange?.startDate ?? '',
|
||||
dateRange?.endDate ?? '',
|
||||
] as const,
|
||||
summaries: () => [...usageLogKeys.all, 'summary'] as const,
|
||||
summary: (period: UsageLogPeriod) => [...usageLogKeys.summaries(), period] as const,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getQueryDataMock } = vi.hoisted(() => ({
|
||||
getQueryDataMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: vi.fn(() => ({
|
||||
getQueryData: getQueryDataMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
|
||||
describe('getWorkflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reads the active workflow list from the cache', () => {
|
||||
const workflows = [{ id: 'wf-1', name: 'Workflow 1' }]
|
||||
getQueryDataMock.mockReturnValue(workflows)
|
||||
|
||||
expect(getWorkflows('ws-1')).toBe(workflows)
|
||||
expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-1', 'active'])
|
||||
})
|
||||
|
||||
it('supports alternate workflow scopes', () => {
|
||||
getQueryDataMock.mockReturnValue([])
|
||||
|
||||
getWorkflows('ws-2', 'archived')
|
||||
|
||||
expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-2', 'archived'])
|
||||
})
|
||||
|
||||
it('reads a single workflow by id from the cache', () => {
|
||||
const workflows = [{ id: 'wf-1', name: 'Workflow 1' }]
|
||||
getQueryDataMock.mockReturnValue(workflows)
|
||||
|
||||
expect(getWorkflowById('ws-1', 'wf-1')).toEqual(workflows[0])
|
||||
expect(getWorkflowById('ws-1', 'missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
const EMPTY_WORKFLOWS: WorkflowMetadata[] = []
|
||||
|
||||
/**
|
||||
* Reads workflow metadata for a workspace directly from the React Query cache.
|
||||
*/
|
||||
export function getWorkflows(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
): WorkflowMetadata[] {
|
||||
return (
|
||||
getQueryClient().getQueryData<WorkflowMetadata[]>(workflowKeys.list(workspaceId, scope)) ??
|
||||
EMPTY_WORKFLOWS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single workflow by id from the React Query cache.
|
||||
*/
|
||||
export function getWorkflowById(
|
||||
workspaceId: string,
|
||||
workflowId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
): WorkflowMetadata | undefined {
|
||||
return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type WorkflowQueryScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export const workflowKeys = {
|
||||
all: ['workflows'] as const,
|
||||
lists: () => [...workflowKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') =>
|
||||
[...workflowKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||
states: () => [...workflowKeys.all, 'state'] as const,
|
||||
state: (workflowId: string | undefined) => [...workflowKeys.states(), workflowId ?? ''] as const,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { QueryFunctionContext } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkflowsContract, type WorkflowListItem } from '@/lib/api/contracts'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
type WorkflowApiRow = WorkflowListItem
|
||||
|
||||
export const WORKFLOW_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata {
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? undefined,
|
||||
workspaceId: workflow.workspaceId ?? undefined,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: new Date(workflow.createdAt),
|
||||
lastModified: new Date(workflow.updatedAt),
|
||||
archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null,
|
||||
locked: workflow.locked,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkflows(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active',
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowMetadata[]> {
|
||||
const { data } = await requestJson(listWorkflowsContract, {
|
||||
query: { workspaceId, scope },
|
||||
signal,
|
||||
})
|
||||
return data.map(mapWorkflow)
|
||||
}
|
||||
|
||||
export function getWorkflowListQueryOptions(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
) {
|
||||
return {
|
||||
queryKey: workflowKeys.list(workspaceId, scope),
|
||||
queryFn: ({ signal }: QueryFunctionContext) => fetchWorkflows(workspaceId, scope, signal),
|
||||
staleTime: WORKFLOW_LIST_STALE_TIME,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts'
|
||||
import { getVoiceSettingsContract } from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* Query key factory for voice settings queries
|
||||
*/
|
||||
export const VOICE_SETTINGS_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
export const voiceSettingsKeys = {
|
||||
all: ['voiceSettings'] as const,
|
||||
availability: () => [...voiceSettingsKeys.all, 'availability'] as const,
|
||||
}
|
||||
|
||||
type VoiceSettingsResponse = ContractJsonResponse<typeof getVoiceSettingsContract>
|
||||
|
||||
async function fetchVoiceSettings(signal?: AbortSignal): Promise<VoiceSettingsResponse> {
|
||||
try {
|
||||
return await requestJson(getVoiceSettingsContract, { signal })
|
||||
} catch {
|
||||
return { sttAvailable: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the server-side voice configuration so clients can conditionally
|
||||
* enable voice input. Returns `{ sttAvailable: false }` on failure rather
|
||||
* than throwing, since STT is an optional capability.
|
||||
*/
|
||||
export function useVoiceSettings() {
|
||||
return useQuery({
|
||||
queryKey: voiceSettingsKeys.availability(),
|
||||
queryFn: ({ signal }) => fetchVoiceSettings(signal),
|
||||
staleTime: VOICE_SETTINGS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ListWebhooksByBlockResponse,
|
||||
listWebhooksByBlockContract,
|
||||
updateWebhookContract,
|
||||
type WebhookData,
|
||||
} from '@/lib/api/contracts/webhooks'
|
||||
|
||||
export const WEBHOOK_DETAIL_STALE_TIME = 60 * 1000
|
||||
|
||||
export const webhookKeys = {
|
||||
all: ['webhooks'] as const,
|
||||
details: () => [...webhookKeys.all, 'detail'] as const,
|
||||
byBlock: (workflowId?: string, blockId?: string) =>
|
||||
[...webhookKeys.details(), workflowId ?? '', blockId ?? ''] as const,
|
||||
}
|
||||
|
||||
export type { WebhookData }
|
||||
|
||||
async function fetchWebhooks(
|
||||
workflowId: string,
|
||||
blockId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WebhookData | null> {
|
||||
let data: ListWebhooksByBlockResponse
|
||||
try {
|
||||
data = await requestJson(listWebhooksByBlockContract, {
|
||||
query: { workflowId, blockId },
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isApiClientError(error) && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data.webhooks && data.webhooks.length > 0) {
|
||||
return data.webhooks[0].webhook
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function useWebhookQuery(workflowId: string, blockId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: webhookKeys.byBlock(workflowId, blockId),
|
||||
queryFn: ({ signal }) => fetchWebhooks(workflowId, blockId, signal),
|
||||
enabled: enabled && Boolean(workflowId && blockId),
|
||||
staleTime: WEBHOOK_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
interface ReactivateWebhookVariables {
|
||||
webhookId: string
|
||||
workflowId: string
|
||||
blockId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivates a disabled webhook and invalidates the block's webhook query so
|
||||
* the shared cache (read by useWebhookQuery / useWebhookManagement) reflects the
|
||||
* new active state immediately instead of serving the stale value for staleTime.
|
||||
*/
|
||||
export function useReactivateWebhook() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ webhookId }: ReactivateWebhookVariables) =>
|
||||
requestJson(updateWebhookContract, {
|
||||
params: { id: webhookId },
|
||||
body: { isActive: true, failedCount: 0 },
|
||||
}),
|
||||
onSettled: (_data, _error, variables) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: webhookKeys.byBlock(variables.workflowId, variables.blockId),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createWorkflowMcpServerContract,
|
||||
createWorkflowMcpToolContract,
|
||||
type DeployedWorkflow,
|
||||
deleteWorkflowMcpServerContract,
|
||||
deleteWorkflowMcpToolContract,
|
||||
getWorkflowMcpServerContract,
|
||||
listWorkflowMcpDeployedWorkflowsContract,
|
||||
listWorkflowMcpServersContract,
|
||||
listWorkflowMcpToolsContract,
|
||||
updateWorkflowMcpServerContract,
|
||||
updateWorkflowMcpToolContract,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/lib/api/contracts/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerQueries')
|
||||
|
||||
export type { DeployedWorkflow }
|
||||
|
||||
/**
|
||||
* Query key factories for Workflow MCP Server queries
|
||||
*/
|
||||
export const workflowMcpServerKeys = {
|
||||
all: ['workflow-mcp-servers'] as const,
|
||||
servers: (workspaceId: string) => [...workflowMcpServerKeys.all, 'servers', workspaceId] as const,
|
||||
server: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.servers(workspaceId), serverId] as const,
|
||||
tools: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const,
|
||||
deployedWorkflows: (workspaceId: string) =>
|
||||
[...workflowMcpServerKeys.all, 'deployed-workflows', workspaceId] as const,
|
||||
}
|
||||
|
||||
export type { WorkflowMcpServer, WorkflowMcpTool }
|
||||
|
||||
export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const WORKFLOW_MCP_TOOLS_STALE_TIME = 30 * 1000
|
||||
export const WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME = 30 * 1000
|
||||
|
||||
/**
|
||||
* Fetch workflow MCP servers for a workspace
|
||||
*/
|
||||
async function fetchWorkflowMcpServers(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowMcpServer[]> {
|
||||
try {
|
||||
const data = await requestJson(listWorkflowMcpServersContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.servers
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workflow MCP servers
|
||||
*/
|
||||
export function useWorkflowMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.servers(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single workflow MCP server with its tools
|
||||
*/
|
||||
async function fetchWorkflowMcpServer(
|
||||
workspaceId: string,
|
||||
serverId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ server: WorkflowMcpServer; tools: WorkflowMcpTool[] }> {
|
||||
const data = await requestJson(getWorkflowMcpServerContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
|
||||
return {
|
||||
server: data.data.server,
|
||||
tools: data.data.tools,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpServer(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.server(workspaceId, serverId || ''),
|
||||
queryFn: ({ signal }) => fetchWorkflowMcpServer(workspaceId, serverId!, signal),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tools for a workflow MCP server
|
||||
*/
|
||||
async function fetchWorkflowMcpTools(
|
||||
workspaceId: string,
|
||||
serverId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowMcpTool[]> {
|
||||
try {
|
||||
const data = await requestJson(listWorkflowMcpToolsContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.tools
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch tools for a workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpTools(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.tools(workspaceId, serverId || ''),
|
||||
queryFn: ({ signal }) => fetchWorkflowMcpTools(workspaceId, serverId!, signal),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: WORKFLOW_MCP_TOOLS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow MCP server mutation
|
||||
*/
|
||||
interface CreateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
name: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
workflowIds?: string[]
|
||||
}
|
||||
|
||||
export function useCreateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
workflowIds,
|
||||
}: CreateWorkflowMcpServerParams) => {
|
||||
const data = await requestJson(createWorkflowMcpServerContract, {
|
||||
body: { workspaceId, name, description, isPublic, workflowIds },
|
||||
})
|
||||
|
||||
logger.info(`Created workflow MCP server: ${name}`)
|
||||
return data.data.server
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow MCP server mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
name?: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
}: UpdateWorkflowMcpServerParams) => {
|
||||
const data = await requestJson(updateWorkflowMcpServerContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
body: { name, description, isPublic },
|
||||
})
|
||||
|
||||
logger.info(`Updated workflow MCP server: ${serverId}`)
|
||||
return data.data.server
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflow MCP server mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: DeleteWorkflowMcpServerParams) => {
|
||||
const data = await requestJson(deleteWorkflowMcpServerContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
logger.info(`Deleted workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tool to workflow MCP server mutation
|
||||
*/
|
||||
interface AddWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
}
|
||||
|
||||
export function useAddWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptionOverrides,
|
||||
}: AddWorkflowMcpToolParams) => {
|
||||
const data = await requestJson(createWorkflowMcpToolContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
body: { workflowId, toolName, toolDescription, parameterDescriptionOverrides },
|
||||
})
|
||||
|
||||
logger.info(`Added tool to workflow MCP server: ${serverId}`)
|
||||
return data.data.tool
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
/**
|
||||
* Full edited schema sent by the Settings edit modal, which has no Start-block context to compute
|
||||
* sparse overrides; the server diffs it against the deployed base to extract the overrides.
|
||||
*/
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
...updates
|
||||
}: UpdateWorkflowMcpToolParams) => {
|
||||
const data = await requestJson(updateWorkflowMcpToolContract, {
|
||||
params: { id: serverId, toolId },
|
||||
query: { workspaceId },
|
||||
body: updates,
|
||||
})
|
||||
|
||||
logger.info(`Updated tool ${toolId} in workflow MCP server: ${serverId}`)
|
||||
return data.data.tool
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tool mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, toolId }: DeleteWorkflowMcpToolParams) => {
|
||||
const data = await requestJson(deleteWorkflowMcpToolContract, {
|
||||
params: { id: serverId, toolId },
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
logger.info(`Deleted tool ${toolId} from workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch deployed workflows for a workspace
|
||||
*/
|
||||
async function fetchDeployedWorkflows(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeployedWorkflow[]> {
|
||||
const { data } = await requestJson(listWorkflowMcpDeployedWorkflowsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
|
||||
return data
|
||||
.filter((w) => w.isDeployed)
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description ?? null,
|
||||
isDeployed: w.isDeployed,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch deployed workflows for a workspace
|
||||
*/
|
||||
export function useDeployedWorkflows(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.deployedWorkflows(workspaceId),
|
||||
queryFn: ({ signal }) => fetchDeployedWorkflows(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
|
||||
import {
|
||||
buildWorkflowSearchMcpToolReplacementOptions,
|
||||
flattenWorkflowSearchReplacementOptions,
|
||||
workflowSearchReplaceKeys,
|
||||
} from '@/hooks/queries/workflow-search-replace'
|
||||
|
||||
function createMcpToolMatch(serverId?: string): WorkflowSearchMatch {
|
||||
return {
|
||||
id: serverId ? `match-${serverId}` : 'match-all',
|
||||
blockId: 'mcp-1',
|
||||
blockName: 'MCP',
|
||||
blockType: 'mcp',
|
||||
subBlockId: 'tool',
|
||||
canonicalSubBlockId: 'tool',
|
||||
subBlockType: 'mcp-tool-selector',
|
||||
valuePath: [],
|
||||
target: { kind: 'subblock' },
|
||||
kind: 'mcp-tool',
|
||||
rawValue: serverId ? `${serverId}-search` : 'search',
|
||||
searchText: 'Search',
|
||||
editable: true,
|
||||
navigable: true,
|
||||
protected: false,
|
||||
resource: {
|
||||
kind: 'mcp-tool',
|
||||
key: serverId ? `${serverId}-search` : 'search',
|
||||
selectorContext: serverId ? { mcpServerId: serverId } : undefined,
|
||||
resourceGroupKey: serverId ? `mcp-tool:${serverId}` : 'mcp-tool:any',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildWorkflowSearchMcpToolReplacementOptions', () => {
|
||||
const tools = [
|
||||
{
|
||||
id: 'a-search',
|
||||
name: 'search',
|
||||
serverId: 'server-a',
|
||||
serverName: 'Server A',
|
||||
inputSchema: {},
|
||||
},
|
||||
{
|
||||
id: 'b-search',
|
||||
name: 'search',
|
||||
serverId: 'server-b',
|
||||
serverName: 'Server B',
|
||||
inputSchema: {},
|
||||
},
|
||||
]
|
||||
|
||||
it('filters MCP tool replacement options to the matched server context', () => {
|
||||
const options = buildWorkflowSearchMcpToolReplacementOptions(
|
||||
[createMcpToolMatch('server-a')],
|
||||
tools
|
||||
)
|
||||
|
||||
expect(options).toEqual([
|
||||
{
|
||||
kind: 'mcp-tool',
|
||||
value: 'mcp-server-a-search',
|
||||
label: 'Server A: search',
|
||||
resourceGroupKey: 'mcp-tool:server-a',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all MCP tool replacement options when no server context exists', () => {
|
||||
const options = buildWorkflowSearchMcpToolReplacementOptions([createMcpToolMatch()], tools)
|
||||
|
||||
expect(options.map((option) => option.value)).toEqual([
|
||||
'mcp-server-a-search',
|
||||
'mcp-server-b-search',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowSearchReplaceKeys', () => {
|
||||
it('builds stable hierarchical keys for credential candidates', () => {
|
||||
expect(
|
||||
workflowSearchReplaceKeys.oauthReplacementOptions('gmail', 'workspace-1', 'workflow-1')
|
||||
).toEqual([
|
||||
'workflow-search-replace',
|
||||
'replacement-options',
|
||||
'oauth',
|
||||
'gmail',
|
||||
'workspace-1',
|
||||
'workflow-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds scoped selector replacement option keys', () => {
|
||||
expect(
|
||||
workflowSearchReplaceKeys.selectorReplacementOptions(
|
||||
'gmail.labels',
|
||||
'{"oauthCredential":"credential-1","workspaceId":"workspace-1"}'
|
||||
)
|
||||
).toEqual([
|
||||
'workflow-search-replace',
|
||||
'replacement-options',
|
||||
'selector',
|
||||
'gmail.labels',
|
||||
'{"oauthCredential":"credential-1","workspaceId":"workspace-1"}',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenWorkflowSearchReplacementOptions', () => {
|
||||
it('flattens loaded option groups while ignoring pending groups', () => {
|
||||
expect(
|
||||
flattenWorkflowSearchReplacementOptions([
|
||||
{ data: [{ kind: 'environment', value: '{{A}}', label: '{{A}}' }] },
|
||||
{},
|
||||
{ data: [{ kind: 'knowledge-base', value: 'kb-1', label: 'KB 1' }] },
|
||||
])
|
||||
).toEqual([
|
||||
{ kind: 'environment', value: '{{A}}', label: '{{A}}' },
|
||||
{ kind: 'knowledge-base', value: 'kb-1', label: 'KB 1' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,663 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
|
||||
import {
|
||||
type DiscoverMcpToolsResponse,
|
||||
discoverMcpToolsContract,
|
||||
type ListMcpServersResponse,
|
||||
listMcpServersContract,
|
||||
} from '@/lib/api/contracts/mcp'
|
||||
import {
|
||||
type GetTableResponse,
|
||||
getTableContract,
|
||||
type ListTablesResponse,
|
||||
listTablesContract,
|
||||
} from '@/lib/api/contracts/tables'
|
||||
import {
|
||||
type ListWorkspaceFilesResponse,
|
||||
listWorkspaceFilesContract,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
import {
|
||||
getWorkflowSearchMatchResourceGroupKey,
|
||||
stableStringifyWorkflowSearchValue,
|
||||
} from '@/lib/workflows/search-replace/resources'
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchReplacementOption,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import { fetchKnowledgeBase, fetchKnowledgeBases } from '@/hooks/queries/kb/knowledge'
|
||||
import {
|
||||
fetchOAuthCredentialDetail,
|
||||
fetchOAuthCredentials,
|
||||
} from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types'
|
||||
|
||||
export interface WorkflowSearchResolvedResource {
|
||||
matchRawValue: string
|
||||
resourceGroupKey?: string
|
||||
label: string
|
||||
resolved: boolean
|
||||
inaccessible: boolean
|
||||
}
|
||||
|
||||
export const workflowSearchReplaceKeys = {
|
||||
all: ['workflow-search-replace'] as const,
|
||||
resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const,
|
||||
oauthDetails: (workflowId?: string) =>
|
||||
[...workflowSearchReplaceKeys.resourceDetails(), 'oauth', workflowId ?? ''] as const,
|
||||
oauthDetail: (credentialId?: string, workflowId?: string) =>
|
||||
[...workflowSearchReplaceKeys.oauthDetails(workflowId), credentialId ?? ''] as const,
|
||||
replacementOptions: () => [...workflowSearchReplaceKeys.all, 'replacement-options'] as const,
|
||||
oauthReplacementOptions: (providerId?: string, workspaceId?: string, workflowId?: string) =>
|
||||
[
|
||||
...workflowSearchReplaceKeys.replacementOptions(),
|
||||
'oauth',
|
||||
providerId ?? '',
|
||||
workspaceId ?? '',
|
||||
workflowId ?? '',
|
||||
] as const,
|
||||
knowledgeDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'knowledge'] as const,
|
||||
knowledgeDetail: (knowledgeBaseId?: string) =>
|
||||
[...workflowSearchReplaceKeys.knowledgeDetails(), knowledgeBaseId ?? ''] as const,
|
||||
tableDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'table'] as const,
|
||||
tableDetail: (workspaceId?: string, tableId?: string) =>
|
||||
[...workflowSearchReplaceKeys.tableDetails(), workspaceId ?? '', tableId ?? ''] as const,
|
||||
tableReplacementOptions: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.replacementOptions(), 'table', workspaceId ?? ''] as const,
|
||||
fileDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'file'] as const,
|
||||
fileListDetails: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.fileDetails(), 'list', workspaceId ?? ''] as const,
|
||||
fileReplacementOptions: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.replacementOptions(), 'file', workspaceId ?? ''] as const,
|
||||
mcpServerDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-server'] as const,
|
||||
mcpServerListDetails: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.mcpServerDetails(), 'list', workspaceId ?? ''] as const,
|
||||
mcpServerReplacementOptions: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.replacementOptions(), 'mcp-server', workspaceId ?? ''] as const,
|
||||
mcpToolDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-tool'] as const,
|
||||
mcpToolListDetails: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.mcpToolDetails(), 'list', workspaceId ?? ''] as const,
|
||||
mcpToolReplacementOptions: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.replacementOptions(), 'mcp-tool', workspaceId ?? ''] as const,
|
||||
knowledgeReplacementOptions: (workspaceId?: string) =>
|
||||
[...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const,
|
||||
selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const,
|
||||
selectorDetail: (selectorKey?: string, contextKey?: string, value?: string) =>
|
||||
[
|
||||
...workflowSearchReplaceKeys.selectorDetails(),
|
||||
selectorKey ?? '',
|
||||
contextKey ?? '',
|
||||
value ?? '',
|
||||
] as const,
|
||||
selectorReplacementOptions: (selectorKey?: string, contextKey?: string) =>
|
||||
[
|
||||
...workflowSearchReplaceKeys.replacementOptions(),
|
||||
'selector',
|
||||
selectorKey ?? '',
|
||||
contextKey ?? '',
|
||||
] as const,
|
||||
}
|
||||
|
||||
export const WORKFLOW_SEARCH_OAUTH_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_KNOWLEDGE_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_TABLE_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_FILE_LIST_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_OAUTH_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_FILE_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
export const WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME = 60 * 1000
|
||||
|
||||
function uniqueMatches(
|
||||
matches: WorkflowSearchMatch[],
|
||||
kind: WorkflowSearchMatch['kind']
|
||||
): WorkflowSearchMatch[] {
|
||||
const seen = new Set<string>()
|
||||
return matches.filter((match) => {
|
||||
if (match.kind !== kind || !match.rawValue || seen.has(match.rawValue)) return false
|
||||
seen.add(match.rawValue)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function selectorContextKey(match: WorkflowSearchMatch): string {
|
||||
return stableStringifyWorkflowSearchValue(match.resource?.selectorContext ?? {})
|
||||
}
|
||||
|
||||
function uniqueSelectorDetailMatches(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] {
|
||||
const seen = new Set<string>()
|
||||
return matches.filter((match) => {
|
||||
const selectorKey = match.resource?.selectorKey
|
||||
if (!selectorKey || !match.rawValue) return false
|
||||
|
||||
const key = `${selectorKey}:${selectorContextKey(match)}:${match.rawValue}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueSelectorOptionGroups(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] {
|
||||
const seen = new Set<string>()
|
||||
return matches.filter((match) => {
|
||||
const selectorKey = match.resource?.selectorKey
|
||||
if (!selectorKey) return false
|
||||
|
||||
const key = `${match.kind}:${selectorKey}:${selectorContextKey(match)}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueResourceOptionGroups(
|
||||
matches: WorkflowSearchMatch[],
|
||||
kind: WorkflowSearchMatch['kind'],
|
||||
predicate?: (match: WorkflowSearchMatch) => boolean
|
||||
): WorkflowSearchMatch[] {
|
||||
const seen = new Set<string>()
|
||||
return matches.filter((match) => {
|
||||
if (match.kind !== kind || predicate?.(match) === false) return false
|
||||
|
||||
const key = getWorkflowSearchMatchResourceGroupKey(match)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchOAuthCredentialDetails(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workflowId?: string
|
||||
) {
|
||||
const oauthMatches = useMemo(() => uniqueMatches(matches, 'oauth-credential'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: oauthMatches.map((match) => ({
|
||||
queryKey: workflowSearchReplaceKeys.oauthDetail(match.rawValue, workflowId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchOAuthCredentialDetail(match.rawValue, workflowId, signal),
|
||||
enabled: Boolean(match.rawValue),
|
||||
staleTime: WORKFLOW_SEARCH_OAUTH_DETAIL_STALE_TIME,
|
||||
select: (credentials: Credential[]): WorkflowSearchResolvedResource => {
|
||||
const credential = credentials[0]
|
||||
return {
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: credential?.name ?? `OAuth credential ${match.rawValue.slice(0, 8)}`,
|
||||
resolved: Boolean(credential?.name),
|
||||
inaccessible: credentials.length === 0,
|
||||
}
|
||||
},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchKnowledgeBaseDetails(matches: WorkflowSearchMatch[]) {
|
||||
const knowledgeMatches = useMemo(() => uniqueMatches(matches, 'knowledge-base'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: knowledgeMatches.map((match) => ({
|
||||
queryKey: workflowSearchReplaceKeys.knowledgeDetail(match.rawValue),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => fetchKnowledgeBase(match.rawValue, signal),
|
||||
enabled: Boolean(match.rawValue),
|
||||
staleTime: WORKFLOW_SEARCH_KNOWLEDGE_DETAIL_STALE_TIME,
|
||||
select: (knowledgeBase: KnowledgeBaseData): WorkflowSearchResolvedResource => ({
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: knowledgeBase.name,
|
||||
resolved: true,
|
||||
inaccessible: false,
|
||||
}),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchTableDetails(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const tableMatches = useMemo(() => uniqueMatches(matches, 'table'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: tableMatches.map((match) => ({
|
||||
queryKey: workflowSearchReplaceKeys.tableDetail(workspaceId, match.rawValue),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(getTableContract, {
|
||||
params: { tableId: match.rawValue },
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && match.rawValue),
|
||||
staleTime: WORKFLOW_SEARCH_TABLE_DETAIL_STALE_TIME,
|
||||
select: (response: GetTableResponse): WorkflowSearchResolvedResource => ({
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: response.data.table.name,
|
||||
resolved: true,
|
||||
inaccessible: false,
|
||||
}),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchFileDetails(matches: WorkflowSearchMatch[], workspaceId?: string) {
|
||||
const fileMatches = useMemo(
|
||||
() =>
|
||||
uniqueMatches(
|
||||
matches.filter((match) => !match.resource?.selectorKey),
|
||||
'file'
|
||||
),
|
||||
[matches]
|
||||
)
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: workflowSearchReplaceKeys.fileListDetails(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(listWorkspaceFilesContract, {
|
||||
params: { id: workspaceId as string },
|
||||
query: { scope: 'active' },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && fileMatches.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_FILE_LIST_STALE_TIME,
|
||||
})
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
fileMatches.map((match) => {
|
||||
const file = filesQuery.data?.files.find((item) =>
|
||||
[item.id, item.key, item.path, item.name].includes(match.rawValue)
|
||||
)
|
||||
return {
|
||||
data: filesQuery.data
|
||||
? {
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: file?.name ?? match.rawValue,
|
||||
resolved: Boolean(file),
|
||||
inaccessible: false,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
[fileMatches, filesQuery.data]
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkflowSearchMcpServerDetails(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const serverMatches = useMemo(() => uniqueMatches(matches, 'mcp-server'), [matches])
|
||||
|
||||
const serversQuery = useQuery({
|
||||
queryKey: workflowSearchReplaceKeys.mcpServerListDetails(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(listMcpServersContract, {
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && serverMatches.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME,
|
||||
})
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
serverMatches.map((match) => {
|
||||
const server = serversQuery.data?.data.servers.find((item) => item.id === match.rawValue)
|
||||
return {
|
||||
data: serversQuery.data
|
||||
? {
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: server?.name ?? match.rawValue,
|
||||
resolved: Boolean(server),
|
||||
inaccessible: false,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
[serverMatches, serversQuery.data]
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkflowSearchMcpToolDetails(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const toolMatches = useMemo(() => uniqueMatches(matches, 'mcp-tool'), [matches])
|
||||
|
||||
const toolsQuery = useQuery({
|
||||
queryKey: workflowSearchReplaceKeys.mcpToolListDetails(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(discoverMcpToolsContract, {
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && toolMatches.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME,
|
||||
})
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
toolMatches.map((match) => {
|
||||
const tool = toolsQuery.data?.data.tools.find(
|
||||
(item) => createMcpToolId(item.serverId, item.name) === match.rawValue
|
||||
)
|
||||
return {
|
||||
data: toolsQuery.data
|
||||
? {
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: tool ? `${tool.serverName}: ${tool.name}` : match.rawValue,
|
||||
resolved: Boolean(tool),
|
||||
inaccessible: false,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
[toolMatches, toolsQuery.data]
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) {
|
||||
const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: selectorMatches.map((match) => {
|
||||
const selectorKey = match.resource?.selectorKey as SelectorKey
|
||||
const context = match.resource?.selectorContext ?? {}
|
||||
const contextKey = selectorContextKey(match)
|
||||
const definition = getSelectorDefinition(selectorKey)
|
||||
const queryArgs = { key: selectorKey, context, detailId: match.rawValue }
|
||||
const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true
|
||||
|
||||
return {
|
||||
queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, contextKey, match.rawValue),
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<SelectorOption | null> => {
|
||||
if (definition.fetchById) {
|
||||
return definition.fetchById({ ...queryArgs, signal })
|
||||
}
|
||||
|
||||
const options = await loadAllSelectorOptions(definition, {
|
||||
key: selectorKey,
|
||||
context,
|
||||
signal,
|
||||
})
|
||||
return options.find((option) => option.id === match.rawValue) ?? null
|
||||
},
|
||||
enabled: Boolean(selectorKey && match.rawValue && baseEnabled),
|
||||
staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME,
|
||||
select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({
|
||||
matchRawValue: match.rawValue,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
label: option?.label ?? match.rawValue,
|
||||
resolved: Boolean(option),
|
||||
inaccessible: false,
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchOAuthReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string,
|
||||
workflowId?: string
|
||||
) {
|
||||
const providerIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
matches.forEach((match) => {
|
||||
if (match.kind === 'oauth-credential' && match.resource?.providerId) {
|
||||
ids.add(match.resource.providerId)
|
||||
}
|
||||
})
|
||||
return [...ids].sort()
|
||||
}, [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: providerIds.map((providerId) => ({
|
||||
queryKey: workflowSearchReplaceKeys.oauthReplacementOptions(
|
||||
providerId,
|
||||
workspaceId,
|
||||
workflowId
|
||||
),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchOAuthCredentials({ providerId, workspaceId, workflowId }, signal),
|
||||
enabled: Boolean(providerId && workspaceId),
|
||||
staleTime: WORKFLOW_SEARCH_OAUTH_REPLACEMENT_STALE_TIME,
|
||||
select: (credentials: Credential[]): WorkflowSearchReplacementOption[] =>
|
||||
credentials.map((credential) => ({
|
||||
kind: 'oauth-credential',
|
||||
value: credential.id,
|
||||
label: credential.name,
|
||||
providerId,
|
||||
serviceId: providerId,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchKnowledgeReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const knowledgeGroups = useMemo(
|
||||
() => uniqueResourceOptionGroups(matches, 'knowledge-base'),
|
||||
[matches]
|
||||
)
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: workflowSearchReplaceKeys.knowledgeReplacementOptions(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchKnowledgeBases(workspaceId, 'active', signal),
|
||||
enabled: Boolean(workspaceId && knowledgeGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME,
|
||||
placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous,
|
||||
select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] =>
|
||||
knowledgeGroups.flatMap((match) =>
|
||||
knowledgeBases.map((knowledgeBase) => ({
|
||||
kind: 'knowledge-base',
|
||||
value: knowledgeBase.id,
|
||||
label: knowledgeBase.name,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchTableReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const tableGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'table'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: workflowSearchReplaceKeys.tableReplacementOptions(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(listTablesContract, {
|
||||
query: { workspaceId: workspaceId as string, scope: 'active' },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && tableGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME,
|
||||
select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] =>
|
||||
tableGroups.flatMap((match) =>
|
||||
response.data.tables.map((table) => ({
|
||||
kind: 'table',
|
||||
value: table.id,
|
||||
label: table.name,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchFileReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const fileGroups = useMemo(
|
||||
() => uniqueResourceOptionGroups(matches, 'file', (match) => !match.resource?.selectorKey),
|
||||
[matches]
|
||||
)
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: workflowSearchReplaceKeys.fileReplacementOptions(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(listWorkspaceFilesContract, {
|
||||
params: { id: workspaceId as string },
|
||||
query: { scope: 'active' },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && fileGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_FILE_REPLACEMENT_STALE_TIME,
|
||||
select: (response: ListWorkspaceFilesResponse): WorkflowSearchReplacementOption[] =>
|
||||
fileGroups.flatMap((match) =>
|
||||
response.files.map((file) => ({
|
||||
kind: 'file',
|
||||
value: JSON.stringify({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}),
|
||||
label: file.name,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchMcpServerReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const serverGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-server'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: workflowSearchReplaceKeys.mcpServerReplacementOptions(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(listMcpServersContract, {
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && serverGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME,
|
||||
select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] =>
|
||||
serverGroups.flatMap((match) =>
|
||||
response.data.servers.map((server) => ({
|
||||
kind: 'mcp-server',
|
||||
value: server.id,
|
||||
label: server.name,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function buildWorkflowSearchMcpToolReplacementOptions(
|
||||
toolGroups: WorkflowSearchMatch[],
|
||||
tools: DiscoverMcpToolsResponse['data']['tools']
|
||||
): WorkflowSearchReplacementOption[] {
|
||||
return toolGroups.flatMap((match) => {
|
||||
const serverId = match.resource?.selectorContext?.mcpServerId
|
||||
return tools
|
||||
.filter((tool) => !serverId || tool.serverId === serverId)
|
||||
.map((tool) => ({
|
||||
kind: 'mcp-tool',
|
||||
value: createMcpToolId(tool.serverId, tool.name),
|
||||
label: `${tool.serverName}: ${tool.name}`,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchMcpToolReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const toolGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-tool'), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: workflowSearchReplaceKeys.mcpToolReplacementOptions(workspaceId),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
requestJson(discoverMcpToolsContract, {
|
||||
query: { workspaceId: workspaceId as string },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(workspaceId && toolGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME,
|
||||
select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] =>
|
||||
buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSearchMatch[]) {
|
||||
const selectorGroups = useMemo(() => uniqueSelectorOptionGroups(matches), [matches])
|
||||
|
||||
return useQueries({
|
||||
queries: selectorGroups.map((match) => {
|
||||
const selectorKey = match.resource?.selectorKey as SelectorKey
|
||||
const context = match.resource?.selectorContext ?? {}
|
||||
const contextKey = selectorContextKey(match)
|
||||
const definition = getSelectorDefinition(selectorKey)
|
||||
const queryArgs = { key: selectorKey, context }
|
||||
const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true
|
||||
|
||||
return {
|
||||
queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, contextKey),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
loadAllSelectorOptions(definition, { ...queryArgs, signal }),
|
||||
enabled: Boolean(selectorKey && baseEnabled),
|
||||
staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME,
|
||||
select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] =>
|
||||
options.map((option) => ({
|
||||
kind: match.kind,
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
selectorKey,
|
||||
selectorContext: context,
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function flattenWorkflowSearchReplacementOptions(
|
||||
optionGroups: Array<{ data?: WorkflowSearchReplacementOption[] }>
|
||||
): WorkflowSearchReplacementOption[] {
|
||||
return optionGroups.flatMap((group) => group.data ?? [])
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* React Query hooks for managing workflow metadata and mutations.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import {
|
||||
keepPreviousData,
|
||||
skipToken,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { revertToDeploymentVersionContract } from '@/lib/api/contracts/deployments'
|
||||
import {
|
||||
createWorkflowContract,
|
||||
deleteWorkflowContract,
|
||||
duplicateWorkflowContract,
|
||||
type GetWorkflowResponseData,
|
||||
type ImportWorkflowAsSuperuserBody,
|
||||
type ImportWorkflowAsSuperuserResponse,
|
||||
importWorkflowAsSuperuserContract,
|
||||
reorderWorkflowsContract,
|
||||
restoreWorkflowContract,
|
||||
updateWorkflowContract,
|
||||
} from '@/lib/api/contracts/workflows'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
|
||||
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import {
|
||||
getWorkflowListQueryOptions,
|
||||
mapWorkflow,
|
||||
WORKFLOW_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/workflow-list-query'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowQueries')
|
||||
|
||||
export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
|
||||
export const WORKFLOW_STATE_STALE_TIME = 30 * 1000
|
||||
export const WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Projects the in-state slice of the workflow envelope into the canvas-facing
|
||||
* `WorkflowState` shape consumed by preview/editor surfaces.
|
||||
*/
|
||||
function mapWorkflowState(data: GetWorkflowResponseData): WorkflowState {
|
||||
const wireState = data.state
|
||||
return {
|
||||
...wireState,
|
||||
loops: wireState.loops ?? {},
|
||||
parallels: wireState.parallels ?? {},
|
||||
deployedAt: wireState.deployedAt ?? undefined,
|
||||
} as WorkflowState
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the full workflow state for a single workflow.
|
||||
* Used by workflow blocks to show a preview of the child workflow
|
||||
* and as a base query for input fields extraction.
|
||||
*
|
||||
* Derives the mapped `WorkflowState` from the shared envelope query via
|
||||
* `select`, so it shares one cache entry (and one request) with the registry
|
||||
* store's hydration and with `useWorkflowStates`.
|
||||
*/
|
||||
export function useWorkflowState(workflowId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.state(workflowId),
|
||||
queryFn: workflowId ? ({ signal }) => fetchWorkflowEnvelope(workflowId, signal) : skipToken,
|
||||
select: mapWorkflowState,
|
||||
staleTime: WORKFLOW_STATE_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched workflow-state fetch for callers that need state for several
|
||||
* workflows at once (e.g. a table with multiple workflow groups). One
|
||||
* subscription per unique workflow id — duplicates in `workflowIds` are
|
||||
* collapsed before subscribing so N consumers of the same id don't each
|
||||
* register their own observer.
|
||||
*/
|
||||
export function useWorkflowStates(
|
||||
workflowIds: ReadonlyArray<string | undefined>
|
||||
): Map<string, WorkflowState | null> {
|
||||
const uniqueIds = Array.from(new Set(workflowIds.filter((id): id is string => Boolean(id))))
|
||||
const results = useQueries({
|
||||
queries: uniqueIds.map((id) => ({
|
||||
queryKey: workflowKeys.state(id),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal),
|
||||
select: mapWorkflowState,
|
||||
staleTime: WORKFLOW_STATE_STALE_TIME,
|
||||
})),
|
||||
})
|
||||
const map = new Map<string, WorkflowState | null>()
|
||||
uniqueIds.forEach((id, i) => {
|
||||
map.set(id, (results[i].data as WorkflowState | null | undefined) ?? null)
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) {
|
||||
const { scope = 'active' } = options || {}
|
||||
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.list(workspaceId, scope),
|
||||
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: WORKFLOW_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
const selectWorkflowMap = (data: WorkflowMetadata[]): Record<string, WorkflowMetadata> =>
|
||||
Object.fromEntries(data.map((w) => [w.id, w]))
|
||||
|
||||
/**
|
||||
* Returns workflows as a `Record<string, WorkflowMetadata>` keyed by ID.
|
||||
* Uses the `select` option so the transformation runs inside React Query
|
||||
* with structural sharing — components only re-render when the record changes.
|
||||
*/
|
||||
export function useWorkflowMap(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) {
|
||||
const { scope = 'active' } = options || {}
|
||||
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.list(workspaceId, scope),
|
||||
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: WORKFLOW_LIST_STALE_TIME,
|
||||
select: selectWorkflowMap,
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateWorkflowVariables {
|
||||
workspaceId: string
|
||||
name?: string
|
||||
description?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
id?: string
|
||||
deduplicate?: boolean
|
||||
}
|
||||
|
||||
interface CreateWorkflowMutationData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
subBlockValues?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function useCreateWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowMutationData> => {
|
||||
const { workspaceId, name, description, folderId, sortOrder, id, deduplicate } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
|
||||
const createdWorkflow = await requestJson(createWorkflowContract, {
|
||||
body: {
|
||||
id,
|
||||
name: name || generateCreativeWorkflowName(),
|
||||
description: description || 'New workflow',
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
deduplicate,
|
||||
},
|
||||
})
|
||||
const workflowId = createdWorkflow.id
|
||||
|
||||
logger.info(`Successfully created workflow ${workflowId}`)
|
||||
|
||||
return {
|
||||
id: workflowId,
|
||||
name: createdWorkflow.name,
|
||||
description: createdWorkflow.description,
|
||||
workspaceId,
|
||||
folderId: createdWorkflow.folderId,
|
||||
sortOrder: createdWorkflow.sortOrder ?? 0,
|
||||
subBlockValues: createdWorkflow.subBlockValues,
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
|
||||
})
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active')
|
||||
)
|
||||
|
||||
const tempId = variables.id ?? generateId()
|
||||
let sortOrder: number
|
||||
if (variables.sortOrder !== undefined) {
|
||||
sortOrder = variables.sortOrder
|
||||
} else {
|
||||
const currentWorkflows = Object.fromEntries(
|
||||
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
|
||||
)
|
||||
sortOrder = getTopInsertionSortOrder(
|
||||
currentWorkflows,
|
||||
getFolderMap(variables.workspaceId),
|
||||
variables.workspaceId,
|
||||
variables.folderId
|
||||
)
|
||||
}
|
||||
|
||||
const optimistic: WorkflowMetadata = {
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
}
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) => [...(old ?? []), optimistic]
|
||||
)
|
||||
logger.info(`[CreateWorkflow] Added optimistic entry: ${tempId}`)
|
||||
|
||||
return { snapshot, tempId }
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
if (!context) return
|
||||
const { tempId } = context
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) =>
|
||||
(old ?? []).map((w) =>
|
||||
w.id === tempId
|
||||
? {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: data.description,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
sortOrder: data.sortOrder,
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
if (tempId !== data.id) {
|
||||
useFolderStore.setState((state) => {
|
||||
const selectedWorkflows = new Set(state.selectedWorkflows)
|
||||
if (selectedWorkflows.has(tempId)) {
|
||||
selectedWorkflows.delete(tempId)
|
||||
selectedWorkflows.add(data.id)
|
||||
}
|
||||
return { selectedWorkflows }
|
||||
})
|
||||
}
|
||||
|
||||
if (data.subBlockValues) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[data.id]: data.subBlockValues!,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
logger.info(`[CreateWorkflow] Success, replaced temp entry ${tempId}`)
|
||||
|
||||
useWorkflowRegistry.getState().markWorkflowCreated(data.id)
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
context.snapshot
|
||||
)
|
||||
logger.info('[CreateWorkflow] Rolled back to previous state')
|
||||
}
|
||||
|
||||
useWorkflowRegistry.getState().markWorkflowCreated(null)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowVariables {
|
||||
workspaceId: string
|
||||
sourceId: string
|
||||
name: string
|
||||
description?: string
|
||||
folderId?: string | null
|
||||
newId?: string
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowMutationData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
locked: boolean
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
|
||||
export function useDuplicateWorkflowMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
variables: DuplicateWorkflowVariables
|
||||
): Promise<DuplicateWorkflowMutationData> => {
|
||||
const { workspaceId, sourceId, name, description, folderId, newId } = variables
|
||||
|
||||
logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`)
|
||||
|
||||
const duplicatedWorkflow = await requestJson(duplicateWorkflowContract, {
|
||||
params: { id: sourceId },
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
workspaceId,
|
||||
folderId: folderId ?? null,
|
||||
newId,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Successfully duplicated workflow ${sourceId} to ${duplicatedWorkflow.id}`, {
|
||||
blocksCount: duplicatedWorkflow.blocksCount,
|
||||
edgesCount: duplicatedWorkflow.edgesCount,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount,
|
||||
})
|
||||
|
||||
return {
|
||||
id: duplicatedWorkflow.id,
|
||||
name: duplicatedWorkflow.name || name,
|
||||
description: duplicatedWorkflow.description || description,
|
||||
workspaceId,
|
||||
folderId: duplicatedWorkflow.folderId ?? folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
locked: duplicatedWorkflow.locked,
|
||||
blocksCount: duplicatedWorkflow.blocksCount || 0,
|
||||
edgesCount: duplicatedWorkflow.edgesCount || 0,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
|
||||
})
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active')
|
||||
)
|
||||
const tempId = variables.newId ?? generateId()
|
||||
|
||||
const currentWorkflows = Object.fromEntries(
|
||||
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
|
||||
)
|
||||
const targetFolderId = variables.folderId ?? null
|
||||
|
||||
const optimistic: WorkflowMetadata = {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder: getTopInsertionSortOrder(
|
||||
currentWorkflows,
|
||||
getFolderMap(variables.workspaceId),
|
||||
variables.workspaceId,
|
||||
targetFolderId
|
||||
),
|
||||
locked: false,
|
||||
}
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) => [...(old ?? []), optimistic]
|
||||
)
|
||||
logger.info(`[DuplicateWorkflow] Added optimistic entry: ${tempId}`)
|
||||
|
||||
return { snapshot, tempId }
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
if (!context) return
|
||||
const { tempId } = context
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) =>
|
||||
(old ?? []).map((w) =>
|
||||
w.id === tempId
|
||||
? {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: data.description,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
sortOrder: data.sortOrder,
|
||||
locked: data.locked,
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
if (tempId !== data.id) {
|
||||
useFolderStore.setState((state) => {
|
||||
const selectedWorkflows = new Set(state.selectedWorkflows)
|
||||
if (selectedWorkflows.has(tempId)) {
|
||||
selectedWorkflows.delete(tempId)
|
||||
selectedWorkflows.add(data.id)
|
||||
}
|
||||
return { selectedWorkflows }
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[DuplicateWorkflow] Success, replaced temp entry ${tempId}`)
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
context.snapshot
|
||||
)
|
||||
logger.info('[DuplicateWorkflow] Rolled back to previous state')
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateWorkflowVariables {
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
metadata: Partial<WorkflowMetadata>
|
||||
}
|
||||
|
||||
export function useUpdateWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: UpdateWorkflowVariables) => {
|
||||
const { workflow: updatedWorkflow } = await requestJson(updateWorkflowContract, {
|
||||
params: { id: variables.workflowId },
|
||||
body: variables.metadata,
|
||||
})
|
||||
|
||||
return mapWorkflow(updatedWorkflow)
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
|
||||
})
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active')
|
||||
)
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) =>
|
||||
(old ?? []).map((w) =>
|
||||
w.id === variables.workflowId
|
||||
? { ...w, ...variables.metadata, lastModified: new Date() }
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
context.snapshot
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DeleteWorkflowVariables {
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: DeleteWorkflowVariables) => {
|
||||
await requestJson(deleteWorkflowContract, {
|
||||
params: { id: variables.workflowId },
|
||||
})
|
||||
|
||||
logger.info(`Successfully deleted workflow ${variables.workflowId} from database`)
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
|
||||
})
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active')
|
||||
)
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) => (old ?? []).filter((w) => w.id !== variables.workflowId)
|
||||
)
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
context.snapshot
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeploymentVersionState(workflowId: string | null, version: number | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined),
|
||||
queryFn:
|
||||
workflowId && version !== null
|
||||
? ({ signal }) => fetchDeploymentVersionState(workflowId, version, signal)
|
||||
: skipToken,
|
||||
staleTime: WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
interface RevertToVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useRevertToVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, version }: RevertToVersionVariables): Promise<void> => {
|
||||
await requestJson(revertToDeploymentVersionContract, {
|
||||
params: { id: workflowId, version },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowKeys.state(variables.workflowId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(variables.workflowId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.deployedState(variables.workflowId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.versions(variables.workflowId),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderWorkflowsVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
folderId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderWorkflows() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderWorkflowsVariables): Promise<void> => {
|
||||
await requestJson(reorderWorkflowsContract, { body: variables })
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
|
||||
})
|
||||
|
||||
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active')
|
||||
)
|
||||
|
||||
const updateMap = new Map(variables.updates.map((u) => [u.id, u]))
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
(old) =>
|
||||
(old ?? []).map((w) => {
|
||||
const update = updateMap.get(w.id)
|
||||
if (!update) return w
|
||||
return {
|
||||
...w,
|
||||
sortOrder: update.sortOrder,
|
||||
folderId: update.folderId !== undefined ? update.folderId : w.folderId,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(
|
||||
workflowKeys.list(variables.workspaceId, 'active'),
|
||||
context.snapshot
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
targetWorkspaceId,
|
||||
}: ImportWorkflowAsSuperuserBody): Promise<ImportWorkflowAsSuperuserResponse> => {
|
||||
return requestJson(importWorkflowAsSuperuserContract, {
|
||||
body: { workflowId, targetWorkspaceId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.targetWorkspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId }: { workflowId: string; workspaceId: string }) => {
|
||||
return requestJson(restoreWorkflowContract, { params: { id: workflowId } })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { toast } from '@sim/emcn'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
bulkArchiveWorkspaceFileItemsContract,
|
||||
createWorkspaceFileFolderContract,
|
||||
listWorkspaceFileFoldersContract,
|
||||
moveWorkspaceFileItemsContract,
|
||||
restoreWorkspaceFileFolderContract,
|
||||
updateWorkspaceFileFolderContract,
|
||||
type WorkspaceFileFolderApi,
|
||||
} from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
type WorkspaceFileFolderScope = 'active' | 'archived' | 'all'
|
||||
export type { WorkspaceFileFolderApi }
|
||||
|
||||
export const WORKSPACE_FILE_FOLDERS_STALE_TIME = 30 * 1000
|
||||
|
||||
export const workspaceFileFolderKeys = {
|
||||
all: ['workspaceFileFolders'] as const,
|
||||
lists: () => [...workspaceFileFolderKeys.all, 'list'] as const,
|
||||
workspaceLists: (workspaceId: string) =>
|
||||
[...workspaceFileFolderKeys.lists(), workspaceId] as const,
|
||||
list: (workspaceId: string, scope: WorkspaceFileFolderScope = 'active') =>
|
||||
[...workspaceFileFolderKeys.workspaceLists(workspaceId), scope] as const,
|
||||
}
|
||||
|
||||
async function fetchWorkspaceFileFolders(
|
||||
workspaceId: string,
|
||||
scope: WorkspaceFileFolderScope,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceFileFolderApi[]> {
|
||||
const data = await requestJson(listWorkspaceFileFoldersContract, {
|
||||
params: { id: workspaceId },
|
||||
query: { scope },
|
||||
signal,
|
||||
})
|
||||
return data.folders
|
||||
}
|
||||
|
||||
function invalidateWorkspaceFileBrowsers(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workspaceId: string
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
}
|
||||
|
||||
export function useWorkspaceFileFolders(
|
||||
workspaceId: string,
|
||||
scope: WorkspaceFileFolderScope = 'active'
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFileFolderKeys.list(workspaceId, scope),
|
||||
queryFn: ({ signal }) => fetchWorkspaceFileFolders(workspaceId, scope, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateWorkspaceFileFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: {
|
||||
workspaceId: string
|
||||
name: string
|
||||
parentId?: string | null
|
||||
}) => {
|
||||
const data = await requestJson(createWorkspaceFileFolderContract, {
|
||||
params: { id: variables.workspaceId },
|
||||
body: { name: variables.name, parentId: variables.parentId },
|
||||
})
|
||||
return data.folder
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceFileFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: {
|
||||
workspaceId: string
|
||||
folderId: string
|
||||
updates: { name?: string; parentId?: string | null; sortOrder?: number }
|
||||
}) => {
|
||||
const data = await requestJson(updateWorkspaceFileFolderContract, {
|
||||
params: { id: variables.workspaceId, folderId: variables.folderId },
|
||||
body: variables.updates,
|
||||
})
|
||||
return data.folder
|
||||
},
|
||||
onMutate: async ({ workspaceId, folderId, updates }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId),
|
||||
})
|
||||
const previous = queryClient.getQueryData<WorkspaceFileFolderApi[]>(
|
||||
workspaceFileFolderKeys.list(workspaceId, 'active')
|
||||
)
|
||||
if (previous) {
|
||||
const target = previous.find((f) => f.id === folderId)
|
||||
const oldPath = target?.path
|
||||
const newPath =
|
||||
updates.name !== undefined && oldPath !== undefined
|
||||
? [...oldPath.split('/').slice(0, -1), updates.name].filter(Boolean).join('/')
|
||||
: oldPath
|
||||
|
||||
queryClient.setQueryData<WorkspaceFileFolderApi[]>(
|
||||
workspaceFileFolderKeys.list(workspaceId, 'active'),
|
||||
previous.map((f) => {
|
||||
if (f.id === folderId) {
|
||||
return { ...f, ...updates, ...(newPath !== undefined ? { path: newPath } : {}) }
|
||||
}
|
||||
// Recompute descendant paths so breadcrumbs stay correct during the optimistic window
|
||||
if (
|
||||
updates.name !== undefined &&
|
||||
oldPath !== undefined &&
|
||||
newPath !== undefined &&
|
||||
f.path?.startsWith(`${oldPath}/`)
|
||||
) {
|
||||
return { ...f, path: `${newPath}${f.path.slice(oldPath.length)}` }
|
||||
}
|
||||
return f
|
||||
})
|
||||
)
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
workspaceFileFolderKeys.list(variables.workspaceId, 'active'),
|
||||
context.previous
|
||||
)
|
||||
}
|
||||
toast.error(toError(err).message)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMoveWorkspaceFileItems() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: {
|
||||
workspaceId: string
|
||||
fileIds: string[]
|
||||
folderIds: string[]
|
||||
targetFolderId?: string | null
|
||||
}) => {
|
||||
return requestJson(moveWorkspaceFileItemsContract, {
|
||||
params: { id: variables.workspaceId },
|
||||
body: {
|
||||
fileIds: variables.fileIds,
|
||||
folderIds: variables.folderIds,
|
||||
targetFolderId: variables.targetFolderId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const total = variables.fileIds.length + variables.folderIds.length
|
||||
toast.success(
|
||||
`Moved ${total} item${total === 1 ? '' : 's'} ${variables.targetFolderId ? 'to folder' : 'to Files'}`
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(toError(error).message)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkArchiveWorkspaceFileItems() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: {
|
||||
workspaceId: string
|
||||
fileIds: string[]
|
||||
folderIds: string[]
|
||||
}) => {
|
||||
return requestJson(bulkArchiveWorkspaceFileItemsContract, {
|
||||
params: { id: variables.workspaceId },
|
||||
body: { fileIds: variables.fileIds, folderIds: variables.folderIds },
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const total = variables.fileIds.length + variables.folderIds.length
|
||||
toast.success(`Moved ${total} item${total === 1 ? '' : 's'} to trash`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(toError(error).message)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreWorkspaceFileFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables: { workspaceId: string; folderId: string }) =>
|
||||
requestJson(restoreWorkspaceFileFolderContract, {
|
||||
params: { id: variables.workspaceId, folderId: variables.folderId },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Folder restored')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(toError(err).message)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getWorkspaceCsvPreviewContract,
|
||||
type WorkspaceCsvPreviewResponse,
|
||||
} from '@/lib/api/contracts/workspace-file-table'
|
||||
|
||||
/**
|
||||
* Query keys for the streamed CSV file-viewer preview. `key` (storage object key) and
|
||||
* `version` (the record's `updatedAt`) are folded in so a re-upload or edit busts the cache.
|
||||
*/
|
||||
export const WORKSPACE_CSV_PREVIEW_STALE_TIME = 30 * 1000
|
||||
|
||||
export const workspaceFileTableKeys = {
|
||||
all: ['workspaceFileTable'] as const,
|
||||
previews: () => [...workspaceFileTableKeys.all, 'preview'] as const,
|
||||
preview: (workspaceId: string, fileId: string, key: string, version?: number) =>
|
||||
[...workspaceFileTableKeys.previews(), workspaceId, fileId, key, version ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchWorkspaceCsvPreview(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
key: string,
|
||||
version: number | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceCsvPreviewResponse> {
|
||||
return requestJson(getWorkspaceCsvPreviewContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
query: version != null ? { key, v: version } : { key },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first {@link CSV_PREVIEW_MAX_ROWS} rows of a CSV via the streaming preview route.
|
||||
* The server reads only that prefix from storage, so this is safe for arbitrarily large files.
|
||||
*/
|
||||
export function useWorkspaceCsvPreview(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
key: string,
|
||||
version?: number,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFileTableKeys.preview(workspaceId, fileId, key, version),
|
||||
queryFn: ({ signal }) => fetchWorkspaceCsvPreview(workspaceId, fileId, key, version, signal),
|
||||
enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true),
|
||||
staleTime: WORKSPACE_CSV_PREVIEW_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* `useWorkspaceFileContent` against REAL react-query (no module mocks): the `refetchInterval`
|
||||
* option must reach the query — the editor's post-stream reconcile depends on it to poll until the
|
||||
* server content advances (see `use-editable-file-content.ts`), and both its consumers' test
|
||||
* setups replace this module, so without this file the passthrough itself would be exercised by
|
||||
* nothing but the type-checker.
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
|
||||
|
||||
let fetchCount = 0
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
fetchCount = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
fetchCount += 1
|
||||
return new Response('# content', { status: 200 })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderContentHook(options?: {
|
||||
refetchInterval?: number | false | (() => number | false)
|
||||
}): { unmount: () => void } {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
|
||||
function Probe() {
|
||||
useWorkspaceFileContent('ws-1', 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options)
|
||||
return null
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Wrapper>
|
||||
<Probe />
|
||||
</Wrapper>
|
||||
)
|
||||
})
|
||||
return {
|
||||
unmount: () => {
|
||||
act(() => root.unmount())
|
||||
queryClient.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWorkspaceFileContent refetchInterval passthrough', () => {
|
||||
it('fetches once and does not poll by default', async () => {
|
||||
const { unmount } = renderContentHook()
|
||||
await act(async () => {
|
||||
await sleep(150)
|
||||
})
|
||||
expect(fetchCount).toBe(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('polls when a numeric refetchInterval is passed', async () => {
|
||||
const { unmount } = renderContentHook({ refetchInterval: 30 })
|
||||
await act(async () => {
|
||||
await sleep(200)
|
||||
})
|
||||
expect(fetchCount).toBeGreaterThanOrEqual(3)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('function form is re-evaluated so flipping its condition stops the polling', async () => {
|
||||
let polling = true
|
||||
const { unmount } = renderContentHook({ refetchInterval: () => (polling ? 30 : false) })
|
||||
await act(async () => {
|
||||
await sleep(200)
|
||||
})
|
||||
expect(fetchCount).toBeGreaterThanOrEqual(3)
|
||||
|
||||
polling = false
|
||||
await act(async () => {
|
||||
await sleep(100)
|
||||
})
|
||||
const settled = fetchCount
|
||||
await act(async () => {
|
||||
await sleep(150)
|
||||
})
|
||||
expect(fetchCount).toBe(settled)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,612 @@
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { backoffWithJitter } from '@sim/utils/retry'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiClientError, isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits'
|
||||
import {
|
||||
deleteWorkspaceFileContract,
|
||||
listWorkspaceFilesContract,
|
||||
registerWorkspaceFileContract,
|
||||
renameWorkspaceFileContract,
|
||||
restoreWorkspaceFileContract,
|
||||
updateWorkspaceFileContentContract,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
import {
|
||||
DirectUploadError,
|
||||
runUploadStrategy,
|
||||
type UploadProgressEvent,
|
||||
} from '@/lib/uploads/client/direct-upload'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import { useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
|
||||
const logger = createLogger('WorkspaceFilesQuery')
|
||||
|
||||
type WorkspaceFileQueryScope = 'active' | 'archived' | 'all'
|
||||
|
||||
/**
|
||||
* Query key factories for workspace files
|
||||
*/
|
||||
export const workspaceFilesKeys = {
|
||||
all: ['workspaceFiles'] as const,
|
||||
lists: () => [...workspaceFilesKeys.all, 'list'] as const,
|
||||
workspaceLists: (workspaceId: string) => [...workspaceFilesKeys.lists(), workspaceId] as const,
|
||||
list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') =>
|
||||
[...workspaceFilesKeys.workspaceLists(workspaceId), scope] as const,
|
||||
contents: () => [...workspaceFilesKeys.all, 'content'] as const,
|
||||
contentFile: (workspaceId: string, fileId: string) =>
|
||||
[...workspaceFilesKeys.contents(), workspaceId, fileId] as const,
|
||||
content: (
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
mode: 'text' | 'raw' | 'binary' = 'text',
|
||||
storageKey?: string
|
||||
) =>
|
||||
[
|
||||
...workspaceFilesKeys.contentFile(workspaceId, fileId),
|
||||
mode,
|
||||
...(storageKey ? [storageKey] : []),
|
||||
] as const,
|
||||
storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const,
|
||||
}
|
||||
|
||||
export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Storage info type
|
||||
*/
|
||||
interface StorageInfo {
|
||||
usedBytes: number
|
||||
limitBytes: number
|
||||
percentUsed: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single workspace file record by ID.
|
||||
* Shares the `list(workspaceId, 'active')` query key with {@link useWorkspaceFiles} so no extra
|
||||
* network request is made when the list is already cached (warm path).
|
||||
* On a cold path (e.g. direct navigation to a file URL), this fetches the full active file list
|
||||
* for the workspace and selects the matching record via `select`.
|
||||
*/
|
||||
export function useWorkspaceFileRecord(workspaceId: string, fileId: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.list(workspaceId, 'active'),
|
||||
queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, 'active', signal),
|
||||
enabled: !!workspaceId && !!fileId,
|
||||
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
|
||||
select: (files) => files.find((f) => f.id === fileId) ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace files from API
|
||||
*/
|
||||
async function fetchWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
scope: WorkspaceFileQueryScope = 'active',
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceFileRecord[]> {
|
||||
const data = await requestJson(listWorkspaceFilesContract, {
|
||||
params: { id: workspaceId },
|
||||
query: { scope },
|
||||
signal,
|
||||
})
|
||||
return data.success ? data.files : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace files
|
||||
*/
|
||||
export function useWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
scope: WorkspaceFileQueryScope = 'active',
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.list(workspaceId, scope),
|
||||
queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, scope, signal),
|
||||
enabled: !!workspaceId && (options?.enabled ?? true),
|
||||
staleTime: WORKSPACE_FILES_LIST_STALE_TIME, // 30 seconds - files can change frequently
|
||||
placeholderData: keepPreviousData, // Show cached data immediately
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file content as text via a content-source URL
|
||||
*/
|
||||
async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Promise<string> {
|
||||
// boundary-raw-fetch: binary/text download, response is not JSON
|
||||
const response = await fetch(url, { signal, cache: 'no-store' })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch file content')
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace file content as text.
|
||||
* `key` (the storage object key) is forwarded into the query key factory so that a new
|
||||
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
|
||||
*
|
||||
* `refetchInterval` lets a caller poll while waiting for the server content to advance — the
|
||||
* editor's post-stream reconcile (see `use-editable-file-content.ts`) exits only when a fetch
|
||||
* returns content that moved past its baseline, and would otherwise wedge read-only forever if
|
||||
* its single refetch raced the agent's write. The function form is re-evaluated by react-query
|
||||
* after every fetch and options pass, so a condition read through a ref stops the polling as soon
|
||||
* as it flips — no re-render required.
|
||||
*/
|
||||
export function useWorkspaceFileContent(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
key: string,
|
||||
raw?: boolean,
|
||||
options?: { refetchInterval?: number | false | (() => number | false) }
|
||||
) {
|
||||
const source = useFileContentSource()
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text', key),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchWorkspaceFileContent(source.buildUrl(key, { raw, bust: true }), signal),
|
||||
enabled: !!workspaceId && !!fileId && !!key,
|
||||
staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME,
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the serve route returns 409 — a generated document (pptx/docx/pdf/
|
||||
* xlsx) whose source is still being written/compiled. Distinct from a real fetch
|
||||
* failure so the binary query can keep retrying (and the preview keeps showing
|
||||
* its loading state) until the compiled artifact is ready.
|
||||
*/
|
||||
export class DocNotReadyError extends Error {
|
||||
constructor() {
|
||||
super('Document is still being generated')
|
||||
this.name = 'DocNotReadyError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch compiled/binary file content via the serve URL.
|
||||
*
|
||||
* A `version` (the file record's `updatedAt`) makes the URL content-immutable: the
|
||||
* serve route marks versioned responses `immutable`, so the browser HTTP cache
|
||||
* resolves re-opens and focus refetches with no round trip. Generated docs are
|
||||
* edited in place (same storage key), so an unversioned caller cannot assume
|
||||
* immutability and instead busts + bypasses the cache to always read fresh. A 409
|
||||
* means a generated doc is still compiling — surfaced as {@link DocNotReadyError}
|
||||
* so the query keeps polling.
|
||||
*/
|
||||
async function fetchWorkspaceFileBinary(
|
||||
url: string,
|
||||
version: string | number | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<ArrayBuffer> {
|
||||
const init: RequestInit = version != null ? { signal } : { signal, cache: 'no-store' }
|
||||
// boundary-raw-fetch: binary download consumed as ArrayBuffer
|
||||
const response = await fetch(url, init)
|
||||
if (response.status === 409) throw new DocNotReadyError()
|
||||
if (!response.ok) throw new Error('Failed to fetch file content')
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace file content as binary (ArrayBuffer).
|
||||
* `key` (the storage object key) is forwarded into the query key factory so that a new
|
||||
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
|
||||
*
|
||||
* `options.version` is a content version (the record's `updatedAt`) folded into the
|
||||
* query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME
|
||||
* storage key — so without a version the cache is never busted and the open
|
||||
* preview keeps showing the stale binary after a regenerate. Versioning the key
|
||||
* makes the preview refetch whenever the file's content changes (and on first
|
||||
* open, keyed to the current content rather than a stale cached entry).
|
||||
*/
|
||||
export function useWorkspaceFileBinary(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
key: string,
|
||||
options?: { enabled?: boolean; version?: string | number }
|
||||
) {
|
||||
const source = useFileContentSource()
|
||||
return useQuery({
|
||||
queryKey:
|
||||
options?.version != null
|
||||
? [...workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), options.version]
|
||||
: workspaceFilesKeys.content(workspaceId, fileId, 'binary', key),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchWorkspaceFileBinary(
|
||||
source.buildUrl(key, { version: options?.version, bust: true }),
|
||||
options?.version,
|
||||
signal
|
||||
),
|
||||
// Callers gate this on a readiness signal (e.g. the file has committed
|
||||
// content) so we don't 409-poll the serve route for a generated doc whose
|
||||
// compiled artifact hasn't been written yet — the doc is fetched once, when
|
||||
// it's actually ready, instead of hammering the serve URL through generation.
|
||||
enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true),
|
||||
staleTime: WORKSPACE_FILE_BINARY_STALE_TIME,
|
||||
refetchOnWindowFocus: 'always',
|
||||
placeholderData: keepPreviousData,
|
||||
// While a generated doc is still compiling, serve returns 409. Poll (stay in
|
||||
// the loading state) until the artifact is ready instead of surfacing an
|
||||
// error. The artifact is written before the source commits, so a fresh serve
|
||||
// normally hits immediately; this only bridges S3 read-after-write lag and the
|
||||
// brief mid-generation window. Poll on a short jittered backoff (~0.6s rising
|
||||
// to ~2.5s, ~30s budget) so the common case recovers fast without hammering the
|
||||
// serve URL on the long tail. SSE content invalidation also re-fetches when the
|
||||
// file actually updates.
|
||||
retry: (failureCount, error) =>
|
||||
error instanceof DocNotReadyError ? failureCount < 14 : failureCount < 2,
|
||||
retryDelay: (failureCount, error) =>
|
||||
error instanceof DocNotReadyError
|
||||
? backoffWithJitter(failureCount, null, { baseMs: 600, maxMs: 2500 })
|
||||
: Math.min(1000 * 2 ** failureCount, 5000),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch storage info from API
|
||||
*/
|
||||
async function fetchStorageInfo(signal?: AbortSignal): Promise<StorageInfo | null> {
|
||||
try {
|
||||
const data = await requestJson(getUsageLimitsContract, { signal })
|
||||
|
||||
if (data.success && data.storage) {
|
||||
return {
|
||||
usedBytes: data.storage.usedBytes,
|
||||
limitBytes: data.storage.limitBytes,
|
||||
percentUsed: data.storage.percentUsed,
|
||||
plan: data.usage?.plan || 'free',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
if (isApiClientError(error) && error.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch storage info
|
||||
*/
|
||||
export function useStorageInfo(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.storageInfo(),
|
||||
queryFn: ({ signal }) => fetchStorageInfo(signal),
|
||||
enabled,
|
||||
retry: false, // Don't retry on 404
|
||||
staleTime: WORKSPACE_STORAGE_INFO_STALE_TIME, // 1 minute - storage info doesn't change often
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload workspace file mutation
|
||||
*/
|
||||
interface UploadFileParams {
|
||||
workspaceId: string
|
||||
file: File
|
||||
folderId?: string | null
|
||||
onProgress?: (event: UploadProgressEvent) => void
|
||||
signal?: AbortSignal
|
||||
skipToast?: boolean
|
||||
skipInvalidation?: boolean
|
||||
}
|
||||
|
||||
interface UploadFileResponse {
|
||||
success: boolean
|
||||
file: UserFile
|
||||
}
|
||||
|
||||
async function uploadViaApiFallback(
|
||||
workspaceId: string,
|
||||
file: File,
|
||||
folderId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<UploadFileResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (folderId) formData.append('folderId', folderId)
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal,
|
||||
})
|
||||
|
||||
return parseUploadResponse(response, 'Upload failed')
|
||||
}
|
||||
|
||||
async function parseUploadResponse(
|
||||
response: Response,
|
||||
fallbackMessage: string
|
||||
): Promise<UploadFileResponse> {
|
||||
let data: { success?: boolean; error?: string; file?: UserFile } | null = null
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {}
|
||||
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(data?.error || `${fallbackMessage} (${response.status})`)
|
||||
}
|
||||
return data as UploadFileResponse
|
||||
}
|
||||
|
||||
async function uploadWorkspaceFile(
|
||||
workspaceId: string,
|
||||
file: File,
|
||||
folderId?: string | null,
|
||||
onProgress?: (event: UploadProgressEvent) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<UploadFileResponse> {
|
||||
let result
|
||||
try {
|
||||
result = await runUploadStrategy({
|
||||
file,
|
||||
presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`,
|
||||
presignedBody: { folderId },
|
||||
workspaceId,
|
||||
context: 'workspace',
|
||||
onProgress,
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
|
||||
return uploadViaApiFallback(workspaceId, file, folderId, signal)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await registerWithRetry(workspaceId, result, folderId, signal)
|
||||
|
||||
if (!data.success || !data.file) {
|
||||
throw new Error(data.error || 'Failed to register file')
|
||||
}
|
||||
return { success: true, file: data.file }
|
||||
}
|
||||
|
||||
const REGISTER_MAX_ATTEMPTS = 3
|
||||
const REGISTER_RETRY_DELAY_MS = 500
|
||||
|
||||
/**
|
||||
* Register the uploaded object with bounded retries. The server-side handler
|
||||
* is idempotent (existing-record short-circuit), so safely retrying handles
|
||||
* dropped responses that would otherwise orphan the object in storage.
|
||||
*/
|
||||
async function registerWithRetry(
|
||||
workspaceId: string,
|
||||
result: { key: string; name: string; contentType: string },
|
||||
folderId?: string | null,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await requestJson(registerWorkspaceFileContract, {
|
||||
params: { id: workspaceId },
|
||||
body: {
|
||||
key: result.key,
|
||||
name: result.name,
|
||||
contentType: result.contentType,
|
||||
folderId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (signal?.aborted) throw error
|
||||
const isTransient =
|
||||
!(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600)
|
||||
if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error
|
||||
await sleep(REGISTER_RETRY_DELAY_MS * attempt)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export function useUploadWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, file, folderId, onProgress, signal }: UploadFileParams) =>
|
||||
uploadWorkspaceFile(workspaceId, file, folderId, onProgress, signal),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
if (variables.skipInvalidation) return
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (!variables.skipToast) {
|
||||
toast.success(`Uploaded "${variables.file.name}"`)
|
||||
}
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
logger.error('Failed to upload file:', error)
|
||||
if (!variables.skipToast) {
|
||||
toast.error(`Failed to upload "${variables.file.name}": ${error.message}`, {
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace file content mutation
|
||||
*/
|
||||
interface UpdateFileContentParams {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
content: string
|
||||
encoding?: 'base64' | 'utf-8'
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceFileContent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId, content, encoding }: UpdateFileContentParams) => {
|
||||
return requestJson(updateWorkspaceFileContentContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
body: encoding ? { content, encoding } : { content },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update file content:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a workspace file
|
||||
*/
|
||||
interface RenameFileParams {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useRenameWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId, name }: RenameFileParams) =>
|
||||
requestJson(renameWorkspaceFileContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
body: { name },
|
||||
}),
|
||||
onMutate: async ({ workspaceId, fileId, name }) => {
|
||||
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
|
||||
const previous = queryClient.getQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId, 'active')
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId, 'active'),
|
||||
previous.map((f) => (f.id === fileId ? { ...f, name } : f))
|
||||
)
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
workspaceFilesKeys.list(variables.workspaceId, 'active'),
|
||||
context.previous
|
||||
)
|
||||
}
|
||||
toast.error(error.message, { duration: 5000 })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workspace file mutation
|
||||
*/
|
||||
interface DeleteFileParams {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId }: DeleteFileParams) =>
|
||||
requestJson(deleteWorkspaceFileContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
}),
|
||||
onMutate: async ({ workspaceId, fileId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
|
||||
|
||||
const previousFiles = queryClient.getQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId, 'active')
|
||||
)
|
||||
|
||||
if (previousFiles) {
|
||||
queryClient.setQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId, 'active'),
|
||||
previousFiles.filter((f) => f.id !== fileId)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousFiles }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousFiles) {
|
||||
queryClient.setQueryData(
|
||||
workspaceFilesKeys.list(variables.workspaceId, 'active'),
|
||||
context.previousFiles
|
||||
)
|
||||
}
|
||||
logger.error('Failed to delete file')
|
||||
toast.error(toError(_err).message)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('File moved to trash')
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
|
||||
})
|
||||
queryClient.removeQueries({
|
||||
queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId }: { workspaceId: string; fileId: string }) =>
|
||||
requestJson(restoreWorkspaceFileContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('File restored')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(toError(err).message)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
createWorkspaceContract,
|
||||
deleteWorkspaceContract,
|
||||
getWorkspaceContract,
|
||||
getWorkspaceMembersContract,
|
||||
getWorkspaceOwnerBillingContract,
|
||||
getWorkspacePermissionsContract,
|
||||
listWorkspacesContract,
|
||||
updateWorkspaceContract,
|
||||
type Workspace,
|
||||
type WorkspaceCreationPolicy,
|
||||
type WorkspaceMember,
|
||||
type WorkspaceOwnerBilling,
|
||||
type WorkspacePermissions,
|
||||
type WorkspaceQueryScope,
|
||||
type WorkspacesResponse,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* 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: (scope: WorkspaceQueryScope = 'active') =>
|
||||
[...workspaceKeys.lists(), 'user', scope] 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,
|
||||
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
|
||||
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
|
||||
ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const,
|
||||
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
|
||||
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
|
||||
}
|
||||
|
||||
export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions }
|
||||
|
||||
export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_LIST_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_SETTINGS_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_MEMBERS_STALE_TIME = 5 * 60 * 1000
|
||||
export const WORKSPACE_ADMIN_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
async function fetchWorkspaces(
|
||||
scope: WorkspaceQueryScope = 'active',
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspacesResponse> {
|
||||
const data = await requestJson(listWorkspacesContract, { query: { scope }, signal })
|
||||
return {
|
||||
workspaces:
|
||||
data.workspaces?.map((workspace: Workspace) => ({
|
||||
...workspace,
|
||||
organizationId: workspace.organizationId ?? null,
|
||||
workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared',
|
||||
inviteMembersEnabled: workspace.inviteMembersEnabled ?? false,
|
||||
inviteDisabledReason: workspace.inviteDisabledReason ?? null,
|
||||
inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false,
|
||||
})) || [],
|
||||
lastActiveWorkspaceId:
|
||||
typeof data.lastActiveWorkspaceId === 'string' ? data.lastActiveWorkspaceId : null,
|
||||
creationPolicy: data.creationPolicy
|
||||
? {
|
||||
...data.creationPolicy,
|
||||
organizationId: data.creationPolicy.organizationId ?? null,
|
||||
reason: data.creationPolicy.reason ?? null,
|
||||
workspaceMode: data.creationPolicy.workspaceMode ?? 'personal',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces
|
||||
|
||||
/**
|
||||
* Fetches the current user's workspaces.
|
||||
* Returns only the workspace array. Use `useWorkspacesWithMetadata` when
|
||||
* you also need `lastActiveWorkspaceId`.
|
||||
*/
|
||||
export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope = 'active') {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.list(scope),
|
||||
queryFn: ({ signal }) => fetchWorkspaces(scope, signal),
|
||||
select: selectWorkspaces,
|
||||
enabled,
|
||||
staleTime: WORKSPACE_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches workspaces with the user's last active workspace ID.
|
||||
* Used by the redirect page to determine which workspace to open.
|
||||
*/
|
||||
export function useWorkspacesWithMetadata(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.list('active'),
|
||||
queryFn: ({ signal }) => fetchWorkspaces('active', signal),
|
||||
enabled,
|
||||
staleTime: WORKSPACE_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceCreationPolicy(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.list('active'),
|
||||
queryFn: ({ signal }) => fetchWorkspaces('active', signal),
|
||||
select: (data) => data.creationPolicy,
|
||||
enabled,
|
||||
staleTime: WORKSPACE_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchWorkspaceOwnerBilling(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceOwnerBilling> {
|
||||
return requestJson(getWorkspaceOwnerBillingContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription access state of the workspace's billed account (its owner's
|
||||
* rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`.
|
||||
* Feed the result to `getSubscriptionAccessState` to gate workspace features on
|
||||
* the owner's plan rather than the viewer's, so a free member of a paid workspace
|
||||
* isn't gated.
|
||||
*
|
||||
* `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan
|
||||
* change happens outside this query's invalidation graph, and the cached value is
|
||||
* shown during the background refetch (no flash), so gates self-heal on reopen.
|
||||
*/
|
||||
export function useWorkspaceOwnerBilling(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''),
|
||||
queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
type CreateWorkspaceParams = Pick<ContractBodyInput<typeof createWorkspaceContract>, 'name'>
|
||||
|
||||
/**
|
||||
* Creates a new workspace.
|
||||
* Merges the created row into the active list cache before invalidation so navigation
|
||||
* cannot race a stale list (see workspace validation fallback in use-workspace-management).
|
||||
*/
|
||||
export function useCreateWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name }: CreateWorkspaceParams) => {
|
||||
const data = await requestJson(createWorkspaceContract, { body: { name } })
|
||||
return data.workspace
|
||||
},
|
||||
onSuccess: (newWorkspace) => {
|
||||
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
|
||||
if (!previous) {
|
||||
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null, creationPolicy: null }
|
||||
}
|
||||
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
|
||||
return previous
|
||||
}
|
||||
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DeleteWorkspaceParams {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a workspace.
|
||||
* Automatically invalidates the workspace list cache on success.
|
||||
*/
|
||||
export function useDeleteWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId }: DeleteWorkspaceParams) => {
|
||||
return requestJson(deleteWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
body: {},
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateWorkspaceParams = { workspaceId: string } & Pick<
|
||||
ContractBodyInput<typeof updateWorkspaceContract>,
|
||||
'name' | 'color' | 'logoUrl'
|
||||
>
|
||||
|
||||
/**
|
||||
* Updates a workspace's properties (name, logo, etc.).
|
||||
* Invalidates both the workspace list and the specific workspace detail cache.
|
||||
*/
|
||||
export function useUpdateWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceParams) => {
|
||||
const body = updates.name !== undefined ? { ...updates, name: updates.name.trim() } : updates
|
||||
return requestJson(updateWorkspaceContract, { params: { id: workspaceId }, body })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchWorkspacePermissions(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspacePermissions> {
|
||||
try {
|
||||
return await requestJson(getWorkspacePermissionsContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
throw new Error('Workspace not found or access denied', { cause: error })
|
||||
}
|
||||
if (error instanceof ApiClientError && error.status === 401) {
|
||||
throw new Error('Authentication required', { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ({ signal }) => fetchWorkspacePermissions(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: WORKSPACE_PERMISSIONS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchWorkspaceMembers(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceMember[]> {
|
||||
const data = await requestJson(getWorkspaceMembersContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.members
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches lightweight member profiles (id, name, image) for a workspace.
|
||||
* Use this for display purposes (avatars, owner cells) instead of the heavier permissions query.
|
||||
*/
|
||||
export function useWorkspaceMembersQuery(workspaceId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.members(workspaceId ?? ''),
|
||||
queryFn: ({ signal }) => fetchWorkspaceMembers(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: WORKSPACE_MEMBERS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchWorkspaceSettings(workspaceId: string, signal?: AbortSignal) {
|
||||
const [settings, permissions] = await Promise.all([
|
||||
requestJson(getWorkspaceContract, { params: { id: workspaceId }, signal }),
|
||||
requestJson(getWorkspacePermissionsContract, { params: { id: workspaceId }, signal }),
|
||||
])
|
||||
|
||||
return {
|
||||
settings,
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch a workspace's settings (and permissions) into the cache. Use on
|
||||
* hover to warm data before navigating to a settings-style route.
|
||||
* @param queryClient - The active QueryClient
|
||||
* @param workspaceId - The workspace ID to prefetch settings for
|
||||
*/
|
||||
export function prefetchWorkspaceSettings(queryClient: QueryClient, workspaceId: string) {
|
||||
if (!workspaceId) return
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: workspaceKeys.settings(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal),
|
||||
staleTime: WORKSPACE_SETTINGS_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches workspace settings including permissions.
|
||||
* @param workspaceId - The workspace ID to fetch settings for
|
||||
*/
|
||||
export function useWorkspaceSettings(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.settings(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: WORKSPACE_SETTINGS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateWorkspaceSettingsParams = { workspaceId: string } & Pick<
|
||||
ContractBodyInput<typeof updateWorkspaceContract>,
|
||||
'billedAccountUserId'
|
||||
>
|
||||
|
||||
/**
|
||||
* Updates workspace settings (e.g., billing configuration).
|
||||
* Invalidates the workspace settings cache on success.
|
||||
*/
|
||||
export function useUpdateWorkspaceSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceSettingsParams) => {
|
||||
return requestJson(updateWorkspaceContract, { params: { id: workspaceId }, body: updates })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.settings(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Workspace with admin access metadata. */
|
||||
export interface AdminWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
isOwner: boolean
|
||||
ownerId?: string
|
||||
canInvite: boolean
|
||||
organizationId: string | null
|
||||
workspaceMode: Workspace['workspaceMode']
|
||||
}
|
||||
|
||||
async function fetchAdminWorkspaces(
|
||||
userId: string | undefined,
|
||||
organizationId: string | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<AdminWorkspace[]> {
|
||||
if (!userId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const workspacesData = await requestJson(listWorkspacesContract, { query: {}, signal })
|
||||
const allUserWorkspaces = (workspacesData.workspaces || []).map((workspace: Workspace) => ({
|
||||
...workspace,
|
||||
organizationId: workspace.organizationId ?? null,
|
||||
workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared',
|
||||
inviteMembersEnabled: workspace.inviteMembersEnabled ?? false,
|
||||
inviteDisabledReason: workspace.inviteDisabledReason ?? null,
|
||||
inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false,
|
||||
}))
|
||||
|
||||
return allUserWorkspaces
|
||||
.filter((workspace: Workspace) => workspace.permissions === 'admin')
|
||||
.filter((workspace: Workspace) =>
|
||||
organizationId
|
||||
? workspace.organizationId === organizationId && workspace.workspaceMode === 'organization'
|
||||
: true
|
||||
)
|
||||
.map((workspace: Workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner: workspace.ownerId === userId,
|
||||
ownerId: workspace.ownerId,
|
||||
canInvite: workspace.inviteMembersEnabled ?? false,
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceMode: workspace.workspaceMode,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches workspaces where the user has admin access.
|
||||
* @param userId - The user ID to check admin access for
|
||||
*/
|
||||
export function useAdminWorkspaces(userId: string | undefined, organizationId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...workspaceKeys.adminList(userId), organizationId ?? ''] as const,
|
||||
queryFn: ({ signal }) => fetchAdminWorkspaces(userId, organizationId, signal),
|
||||
enabled: Boolean(userId),
|
||||
staleTime: WORKSPACE_ADMIN_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user