Compare commits

...

6 Commits

Author SHA1 Message Date
waleed
91ec8ef2a6 fix(kbtags): added time to date tag, improved ui ux throughout the kb 2026-01-30 20:37:48 -08:00
Vikhyath Mondreti
cf2f1abcaf fix(executor): condition inside parallel (#3094)
* fix(executor): condition inside parallel

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

* ack PR comments

* remove dead import

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

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

View File

@@ -202,7 +202,6 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -233,7 +232,6 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -262,7 +260,6 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -292,7 +289,6 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -325,7 +321,6 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -346,7 +341,6 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -380,7 +374,6 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -413,7 +406,6 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
})
@@ -427,4 +419,97 @@ describe('Knowledge Search Utils', () => {
expect(result).toEqual({})
})
})
describe('Date Filter Format Handling', () => {
it('should accept date-only format (YYYY-MM-DD) in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp format in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15T14:30:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with UTC timezone in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'gte',
value: '2024-01-15T14:30:00Z',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with timezone offset in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'lt',
value: '2024-01-15T14:30:00+05:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should support all date comparison operators', () => {
const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']
const validDateValue = '2024-01-15'
for (const operator of operators) {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator,
value: validDateValue,
}
expect(filter.operator).toBe(operator)
}
})
it('should support between operator with date range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01',
valueTo: '2024-12-31',
}
expect(filter.operator).toBe('between')
expect(filter.value).toBe('2024-01-01')
expect(filter.valueTo).toBe('2024-12-31')
})
it('should support between operator with timestamp range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01T00:00:00',
valueTo: '2024-12-31T23:59:59',
}
expect(filter.operator).toBe('between')
expect(filter.value).toMatch(/T\d{2}:\d{2}:\d{2}$/)
expect(filter.valueTo).toMatch(/T\d{2}:\d{2}:\d{2}$/)
})
})
})

View File

@@ -203,39 +203,74 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
}
}
// Handle date operators - expects YYYY-MM-DD format from frontend
// Handle date operators - accepts YYYY-MM-DD or ISO 8601 timestamp
if (fieldType === 'date') {
const dateStr = String(value)
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
const datetimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
// Validate format - accept date-only or timestamp
const isDateOnly = dateOnlyRegex.test(dateStr)
const isTimestamp = datetimeRegex.test(dateStr)
if (!isDateOnly && !isTimestamp) {
logger.debug(
`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss`
)
return null
}
// Use date comparison for date-only values, timestamp comparison for timestamps
const castType = isDateOnly ? '::date' : '::timestamp'
switch (operator) {
case 'eq':
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
case 'neq':
return sql`${column}::date != ${dateStr}::date`
return isDateOnly
? sql`${column}::date != ${dateStr}::date`
: sql`${column}::timestamp != ${dateStr}::timestamp`
case 'gt':
return sql`${column}::date > ${dateStr}::date`
return isDateOnly
? sql`${column}::date > ${dateStr}::date`
: sql`${column}::timestamp > ${dateStr}::timestamp`
case 'gte':
return sql`${column}::date >= ${dateStr}::date`
return isDateOnly
? sql`${column}::date >= ${dateStr}::date`
: sql`${column}::timestamp >= ${dateStr}::timestamp`
case 'lt':
return sql`${column}::date < ${dateStr}::date`
return isDateOnly
? sql`${column}::date < ${dateStr}::date`
: sql`${column}::timestamp < ${dateStr}::timestamp`
case 'lte':
return sql`${column}::date <= ${dateStr}::date`
return isDateOnly
? sql`${column}::date <= ${dateStr}::date`
: sql`${column}::timestamp <= ${dateStr}::timestamp`
case 'between':
if (valueTo !== undefined) {
const dateStrTo = String(valueTo)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
return sql`${column}::date = ${dateStr}::date`
const isToDateOnly = dateOnlyRegex.test(dateStrTo)
const isToTimestamp = datetimeRegex.test(dateStrTo)
if (!isToDateOnly && !isToTimestamp) {
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
}
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
// Use date comparison if both are date-only, otherwise use timestamp
if (isDateOnly && isToDateOnly) {
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
}
return sql`${column}::timestamp >= ${dateStr}::timestamp AND ${column}::timestamp <= ${dateStrTo}::timestamp`
}
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
default:
return sql`${column}::date = ${dateStr}::date`
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
}
}

View File

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

View File

@@ -146,6 +146,8 @@ export default function PlaygroundPage() {
const [isDarkMode, setIsDarkMode] = useState(false)
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
const [dateValue, setDateValue] = useState('')
const [dateTimeValue, setDateTimeValue] = useState('')
const [dateTimePreset, setDateTimePreset] = useState('2025-01-30T14:30:00')
const [dateRangeStart, setDateRangeStart] = useState('')
const [dateRangeEnd, setDateRangeEnd] = useState('')
const [tagItems, setTagItems] = useState<TagItem[]>([
@@ -708,6 +710,30 @@ export default function PlaygroundPage() {
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
</VariantRow>
<VariantRow label='with time (empty)'>
<div className='w-72'>
<DatePicker
value={dateTimeValue}
onChange={setDateTimeValue}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>
{dateTimeValue || 'No value'}
</span>
</VariantRow>
<VariantRow label='with time (preset)'>
<div className='w-72'>
<DatePicker
value={dateTimePreset}
onChange={setDateTimePreset}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateTimePreset}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-56'>
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />

View File

@@ -25,6 +25,10 @@ interface ChunkContextMenuProps {
* Empty space action (shown when right-clicking on empty space)
*/
onAddChunk?: () => void
/**
* View document tags (shown when right-clicking on empty space)
*/
onViewTags?: () => void
/**
* Whether the chunk is currently enabled
*/
@@ -75,6 +79,7 @@ export function ChunkContextMenu({
onToggleEnabled,
onDelete,
onAddChunk,
onViewTags,
isChunkEnabled = true,
hasChunk,
disableToggleEnabled = false,
@@ -181,17 +186,29 @@ export function ChunkContextMenu({
)}
</>
) : (
onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)
<>
{onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)}
{onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
onClose()
}}
>
View tags
</PopoverItem>
)}
</>
)}
</PopoverContent>
</Popover>

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Badge,
Button,
Combobox,
DatePicker,
@@ -384,7 +385,7 @@ export function DocumentTagsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
@@ -405,9 +406,9 @@ export function DocumentTagsModal({
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
<Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
</Badge>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{formatValueForDisplay(tag.value, tag.fieldType)}
@@ -419,9 +420,9 @@ export function DocumentTagsModal({
e.stopPropagation()
handleRemoveTag(index)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
@@ -526,7 +527,8 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
/>
) : (
<Input
@@ -679,7 +681,8 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
/>
) : (
<Input

View File

@@ -641,7 +641,7 @@ export function Document({
variant='default'
className='h-[32px] rounded-[6px]'
>
Tags
Document tags
</Button>
)}
<Tooltip.Root>
@@ -864,10 +864,7 @@ export function Document({
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
>
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<SearchHighlight
text={truncateContent(chunk.content, 150, searchQuery)}
searchQuery={searchQuery}
@@ -1152,6 +1149,7 @@ export function Document({
? () => setIsCreateChunkModalOpen(true)
: undefined
}
onViewTags={() => setShowTagsModal(true)}
disableToggleEnabled={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'}

View File

@@ -49,6 +49,7 @@ import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowl
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
import {
ActionBar,
AddDocumentsModal,
@@ -367,6 +368,8 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
const [documentForTags, setDocumentForTags] = useState<DocumentData | null>(null)
const {
isOpen: isContextMenuOpen,
@@ -525,7 +528,6 @@ export function KnowledgeBase({
const newEnabled = !document.enabled
// Optimistic update
updateDocument(docId, { enabled: newEnabled })
updateDocumentMutation(
@@ -536,7 +538,6 @@ export function KnowledgeBase({
},
{
onError: () => {
// Rollback on error
updateDocument(docId, { enabled: !newEnabled })
},
}
@@ -547,7 +548,6 @@ export function KnowledgeBase({
* Handles retrying a failed document processing
*/
const handleRetryDocument = (docId: string) => {
// Optimistic update
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
@@ -593,7 +593,6 @@ export function KnowledgeBase({
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
// Optimistic update
updateDocument(documentId, { filename: newName })
return new Promise<void>((resolve, reject) => {
@@ -609,7 +608,6 @@ export function KnowledgeBase({
resolve()
},
onError: (err) => {
// Rollback on error
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
@@ -973,7 +971,7 @@ export function KnowledgeBase({
variant='default'
className='h-[32px] rounded-[6px]'
>
Tags
Tag definitions
</Button>
)}
<Tooltip.Root>
@@ -1221,17 +1219,9 @@ export function KnowledgeBase({
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={doc.filename}
>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{doc.filename}</Tooltip.Content>
</Tooltip.Root>
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
</div>
</TableCell>
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
@@ -1556,6 +1546,22 @@ export function KnowledgeBase({
/>
)}
{/* Document Tags Modal */}
{documentForTags && (
<DocumentTagsModal
open={showDocumentTagsModal}
onOpenChange={setShowDocumentTagsModal}
knowledgeBaseId={id}
documentId={documentForTags.id}
documentData={documentForTags}
onDocumentUpdate={(updates) => {
Object.entries(updates).forEach(([key, value]) => {
updateDocument(documentForTags.id, { [key]: value })
})
}}
/>
)}
<ActionBar
selectedCount={selectedDocuments.size}
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
@@ -1624,13 +1630,8 @@ export function KnowledgeBase({
onViewTags={
contextMenuDocument && selectedDocuments.size === 1
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
docName: contextMenuDocument.filename || 'Document',
})
router.push(
`/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
)
setDocumentForTags(contextMenuDocument)
setShowDocumentTagsModal(true)
}
: undefined
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -22,7 +22,12 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
import {
type TagUsageData,
useCreateTagDefinition,
useDeleteTagDefinition,
useTagUsageQuery,
} from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal')
@@ -33,13 +38,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
boolean: 'Boolean',
}
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
@@ -91,45 +89,23 @@ interface BaseTagsModalProps {
}
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const createTagMutation = useCreateTagDefinition()
const deleteTagMutation = useDeleteTagDefinition()
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(
open ? knowledgeBaseId : null
)
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
const fetchTagUsage = useCallback(async () => {
if (!knowledgeBaseId) return
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
if (result.success) {
setTagUsageData(result.data)
}
} catch (error) {
logger.error('Error fetching tag usage:', error)
}
}, [knowledgeBaseId])
useEffect(() => {
if (open) {
fetchTagUsage()
}
}, [open, fetchTagUsage])
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
@@ -143,13 +119,29 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
setDeleteTagDialogOpen(true)
const { data: freshTagUsage } = await refetchTagUsage()
const tagUsage = freshTagUsage?.find((usage) => usage.tagSlot === tag.tagSlot)
const documentCount = tagUsage?.documentCount ?? 0
if (documentCount === 0) {
try {
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: tag.id,
})
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
}
} else {
setDeleteTagDialogOpen(true)
}
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
await refetchTagUsage()
setViewDocumentsDialogOpen(true)
}
@@ -219,8 +211,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
fieldType: createTagForm.fieldType,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
displayName: '',
fieldType: 'text',
@@ -240,8 +230,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
tagDefinitionId: selectedTag.id,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setDeleteTagDialogOpen(false)
setSelectedTag(null)
} catch (error) {
@@ -265,7 +253,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
@@ -315,9 +303,10 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Tag</span>
</Button>
</div>
</div>
@@ -331,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
className='w-full'
>
Add Tag
Add tag definition
</Button>
)}
@@ -415,7 +404,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* Delete Tag Confirmation Dialog */}
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>
@@ -458,7 +447,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* View Documents Dialog */}
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='sm'>
<ModalContent size='md'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
@@ -15,6 +15,7 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const logger = createLogger('KnowledgeHeader')
@@ -55,43 +56,23 @@ interface Workspace {
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const updateKnowledgeBase = useUpdateKnowledgeBase()
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
!!options?.knowledgeBaseId
)
useEffect(() => {
if (!options?.knowledgeBaseId) return
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoadingWorkspaces(false)
}
}
fetchWorkspaces()
}, [options?.knowledgeBaseId])
const workspaces = useMemo<Workspace[]>(
() =>
allWorkspaces
.filter((ws) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions as 'admin' | 'write' | 'read',
})),
[allWorkspaces]
)
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useKnowledgeBasesQuery } from '@/hooks/queries/knowledge'
import { useRecentLogs } from '@/hooks/queries/logs'
import { useTemplates } from '@/hooks/queries/templates'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -107,10 +110,10 @@ export interface MentionDataReturn {
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => void
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
ensureTemplatesLoaded: () => void
ensureLogsLoaded: () => void
}
/**
@@ -128,8 +131,20 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const [pastChats, setPastChats] = useState<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false)
const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery(
workspaceId,
{ enabled: shouldLoadKnowledge }
)
const knowledgeBases = useMemo<KnowledgeItem[]>(() => {
const sorted = [...knowledgeData].sort((a, b) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
return sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))
}, [knowledgeData])
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
@@ -138,11 +153,39 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
setBlocksList([])
}, [config.allowedIntegrations])
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
const [shouldLoadTemplates, setShouldLoadTemplates] = useState(false)
const { data: templatesData, isLoading: isLoadingTemplates } = useTemplates(
{ limit: 50, offset: 0 },
{ enabled: shouldLoadTemplates }
)
const [logsList, setLogsList] = useState<LogItem[]>([])
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const templatesList = useMemo<TemplateItem[]>(() => {
const items = templatesData?.data ?? []
return items
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a, b) => b.stars - a.stars)
}, [templatesData])
const [shouldLoadLogs, setShouldLoadLogs] = useState(false)
const { data: logsData = [], isLoading: isLoadingLogs } = useRecentLogs(workspaceId, 50, {
enabled: shouldLoadLogs,
})
const logsList = useMemo<LogItem[]>(
() =>
logsData.map((l) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
})),
[logsData]
)
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
@@ -191,7 +234,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
}
try {
// Fetch current blocks from store
const workflowStoreBlocks = useWorkflowStore.getState().blocks
const { registry: blockRegistry } = await import('@/blocks/registry')
@@ -248,25 +290,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures knowledge bases are loaded
*/
const ensureKnowledgeLoaded = useCallback(async () => {
if (isLoadingKnowledge || knowledgeBases.length > 0) return
try {
setIsLoadingKnowledge(true)
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const sorted = [...items].sort((a: any, b: any) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
} catch {
} finally {
setIsLoadingKnowledge(false)
const ensureKnowledgeLoaded = useCallback(() => {
if (!shouldLoadKnowledge) {
setShouldLoadKnowledge(true)
}
}, [isLoadingKnowledge, knowledgeBases.length, workspaceId])
}, [shouldLoadKnowledge])
/**
* Ensures blocks are loaded
@@ -319,55 +347,22 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures templates are loaded
*/
const ensureTemplatesLoaded = useCallback(async () => {
if (isLoadingTemplates || templatesList.length > 0) return
try {
setIsLoadingTemplates(true)
const resp = await fetch('/api/templates?limit=50&offset=0')
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a: any, b: any) => b.stars - a.stars)
setTemplatesList(mapped)
} catch {
} finally {
setIsLoadingTemplates(false)
const ensureTemplatesLoaded = useCallback(() => {
if (!shouldLoadTemplates) {
setShouldLoadTemplates(true)
}
}, [isLoadingTemplates, templatesList.length])
}, [shouldLoadTemplates])
/**
* Ensures logs are loaded
*/
const ensureLogsLoaded = useCallback(async () => {
if (isLoadingLogs || logsList.length > 0) return
try {
setIsLoadingLogs(true)
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items.map((l: any) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
}))
setLogsList(mapped)
} catch {
} finally {
setIsLoadingLogs(false)
const ensureLogsLoaded = useCallback(() => {
if (!shouldLoadLogs) {
setShouldLoadLogs(true)
}
}, [isLoadingLogs, logsList.length, workspaceId])
}, [shouldLoadLogs])
return {
// State
pastChats,
isLoadingPastChats,
workflows,
@@ -382,8 +377,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
isLoadingLogs,
workflowBlocks,
isLoadingWorkflowBlocks,
// Operations
ensurePastChatsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const checkboxVariants = cva(
'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
].join(' '),
{
variants: {

View File

@@ -20,7 +20,7 @@ import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -460,7 +460,7 @@ const Combobox = memo(
<Input
ref={inputRef}
className={cn(
'w-full pr-[40px] font-medium transition-colors hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'w-full pr-[40px] font-medium transition-colors',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-[28px]',
className

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -82,10 +82,12 @@ interface DatePickerBaseProps
interface DatePickerSingleProps extends DatePickerBaseProps {
/** Selection mode */
mode?: 'single'
/** Current selected date value (YYYY-MM-DD string or Date) */
/** Current selected date value (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss string, or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD format */
/** Callback when date changes, returns YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format */
onChange?: (value: string) => void
/** When true, shows time picker after date selection and outputs ISO 8601 format */
showTime?: boolean
/** Not used in single mode */
startDate?: never
/** Not used in single mode */
@@ -177,14 +179,91 @@ const MONTHS_SHORT = [
/**
* Formats a date for display in the trigger button.
* If time is provided, formats as "Jan 30, 2026 at 2:30 PM"
*/
function formatDateForDisplay(date: Date | null): string {
function formatDateForDisplay(date: Date | null, time?: string | null): string {
if (!date) return ''
return date.toLocaleDateString('en-US', {
const dateStr = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (time) {
return `${dateStr} at ${formatDisplayTime(time)}`
}
return dateStr
}
/**
* Converts a 24h time string to 12h display format with AM/PM.
*/
function formatDisplayTime(time: string): string {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
/**
* Converts 12h time components to 24h format string.
*/
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
/**
* Parses a 24h time string into 12h components.
* Returns default 12:00 PM if no time provided (for UI display only).
*/
function parseTimeComponents(time: string | null): {
hour: string
minute: string
ampm: 'AM' | 'PM'
} {
if (!time) return { hour: '12', minute: '00', ampm: 'PM' }
const [hours, minutes] = time.split(':')
const hour24 = Number.parseInt(hours, 10)
const isAM = hour24 < 12
return {
hour: (hour24 % 12 || 12).toString(),
minute: minutes || '00',
ampm: isAM ? 'AM' : 'PM',
}
}
/**
* Checks if a value contains time information.
*/
function valueHasTime(value: string | Date | undefined): boolean {
if (!value) return false
if (value instanceof Date) {
// Check if time is not midnight (default)
return value.getHours() !== 0 || value.getMinutes() !== 0
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm
return /T\d{2}:\d{2}/.test(value)
}
/**
* Extracts time from a datetime string or Date object.
* Returns HH:mm format or null if no time present.
*/
function extractTimeFromValue(value: string | Date | undefined): string | null {
if (!value) return null
if (value instanceof Date) {
// Only return time if it's not midnight (which could be default)
if (value.getHours() === 0 && value.getMinutes() === 0) return null
return `${value.getHours().toString().padStart(2, '0')}:${value.getMinutes().toString().padStart(2, '0')}`
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm:ss
const match = value.match(/T(\d{2}):(\d{2})/)
if (match) {
return `${match[1]}:${match[2]}`
}
return null
}
/**
@@ -228,12 +307,16 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
/**
* Formats a date as YYYY-MM-DD string.
* Formats a date as YYYY-MM-DD string, optionally with time as YYYY-MM-DDTHH:mm:ss.
*/
function formatDateAsString(year: number, month: number, day: number): string {
function formatDateAsString(year: number, month: number, day: number, time?: string): string {
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
const dateStr = `${year}-${m}-${d}`
if (time) {
return `${dateStr}T${time}:00`
}
return dateStr
}
/**
@@ -498,6 +581,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const {
value: _value,
onChange: _onChange,
showTime: _showTime,
startDate: _startDate,
endDate: _endDate,
onRangeChange: _onRangeChange,
@@ -507,6 +591,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
} = rest as any
const isRangeMode = props.mode === 'range'
const showTime = !isRangeMode && (props as DatePickerSingleProps).showTime === true
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = React.useState(false)
@@ -524,6 +609,37 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const selectedDate = !isRangeMode ? parseDate(props.value) : null
// Time state for showTime mode
// Track whether the incoming value has time
const valueTimeInfo = React.useMemo(() => {
if (!showTime) return { hasTime: false, time: null }
const time = extractTimeFromValue(props.value)
return { hasTime: time !== null, time }
}, [showTime, props.value])
const parsedTime = React.useMemo(
() => parseTimeComponents(valueTimeInfo.time),
[valueTimeInfo.time]
)
const [hour, setHour] = React.useState(parsedTime.hour)
const [minute, setMinute] = React.useState(parsedTime.minute)
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsedTime.ampm)
// Track whether user has explicitly set time (either from value or interaction)
const [timeWasSet, setTimeWasSet] = React.useState(valueTimeInfo.hasTime)
const hourInputRef = React.useRef<HTMLInputElement>(null)
// Sync time state when value changes
React.useEffect(() => {
if (showTime) {
const time = extractTimeFromValue(props.value)
const newParsed = parseTimeComponents(time)
setHour(newParsed.hour)
setMinute(newParsed.minute)
setAmpm(newParsed.ampm)
setTimeWasSet(time !== null)
}
}, [showTime, props.value])
const initialStart = isRangeMode ? parseDate(props.startDate) : null
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
@@ -566,17 +682,186 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [isRangeMode, selectedDate])
/**
* Gets the current time string in 24h format.
*/
const getCurrentTimeString = React.useCallback(() => {
const h = Number.parseInt(hour) || 12
const m = Number.parseInt(minute) || 0
return formatStorageTime(h, m, ampm)
}, [hour, minute, ampm])
/**
* Handles selection of a specific day in single mode.
*/
const handleSelectDateSingle = React.useCallback(
(day: number) => {
if (!isRangeMode && props.onChange) {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
setOpen(false)
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(formatDateAsString(viewYear, viewMonth, day, getCurrentTimeString()))
} else {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
if (!showTime) {
setOpen(false)
}
}
}
},
[isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
[
isRangeMode,
viewYear,
viewMonth,
props.onChange,
setOpen,
showTime,
getCurrentTimeString,
timeWasSet,
]
)
/**
* Handles hour input change.
*/
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setHour(val)
}, [])
/**
* Handles hour input blur - validates and updates value.
*/
const handleHourBlur = React.useCallback(() => {
const numVal = Number.parseInt(hour) || 12
const clamped = Math.min(12, Math.max(1, numVal))
setHour(clamped.toString())
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(clamped, Number.parseInt(minute) || 0, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [hour, minute, ampm, selectedDate, props.onChange, showTime])
/**
* Handles minute input change.
*/
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setMinute(val)
}, [])
/**
* Handles minute input blur - validates and updates value.
*/
const handleMinuteBlur = React.useCallback(() => {
const numVal = Number.parseInt(minute) || 0
const clamped = Math.min(59, Math.max(0, numVal))
setMinute(clamped.toString().padStart(2, '0'))
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(Number.parseInt(hour) || 12, clamped, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [minute, hour, ampm, selectedDate, props.onChange, showTime])
/**
* Handles AM/PM toggle.
*/
const handleAmpmChange = React.useCallback(
(newAmpm: 'AM' | 'PM') => {
setAmpm(newAmpm)
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(
Number.parseInt(hour) || 12,
Number.parseInt(minute) || 0,
newAmpm
)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
},
[hour, minute, selectedDate, props.onChange, showTime]
)
/**
* Handles keyboard navigation in hour input (Enter, ArrowUp, ArrowDown).
*/
const handleHourKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num >= 12 ? 1 : num + 1
return next.toString()
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num <= 1 ? 12 : num - 1
return next.toString()
})
}
},
[setOpen, timeWasSet]
)
/**
* Handles keyboard navigation in minute input (Enter, ArrowUp, ArrowDown).
*/
const handleMinuteKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num >= 59 ? 0 : num + 1
return next.toString().padStart(2, '0')
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num <= 0 ? 59 : num - 1
return next.toString().padStart(2, '0')
})
}
},
[setOpen, timeWasSet]
)
/**
@@ -640,16 +925,31 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
/**
* Selects today's date (single mode only).
* Preserves existing time if set, otherwise outputs date only.
*/
const handleSelectToday = React.useCallback(() => {
if (!isRangeMode && props.onChange) {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false)
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(
formatDateAsString(
now.getFullYear(),
now.getMonth(),
now.getDate(),
getCurrentTimeString()
)
)
} else {
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
if (!showTime) {
setOpen(false)
}
}
}
}, [isRangeMode, props.onChange, setOpen])
}, [isRangeMode, props.onChange, setOpen, showTime, getCurrentTimeString, timeWasSet])
/**
* Applies the selected range (range mode only).
@@ -710,9 +1010,11 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [disabled, open, setOpen])
// Only show time in display if it was explicitly set
const displayTime = showTime && timeWasSet ? getCurrentTimeString() : null
const displayValue = isRangeMode
? formatDateRangeForDisplay(initialStart, initialEnd)
: formatDateForDisplay(selectedDate)
: formatDateForDisplay(selectedDate, displayTime)
const calendarContent = isRangeMode ? (
<>
@@ -783,12 +1085,81 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
onNextMonth={goToNextMonth}
/>
{/* Today Button */}
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
{/* Time Picker (when showTime is enabled) */}
{showTime && (
<div className='flex items-center justify-center gap-[6px] border-[var(--border-1)] border-t px-[12px] py-[10px]'>
<span className='font-medium text-[12px] text-[var(--text-muted)]'>Time:</span>
<input
ref={hourInputRef}
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={hour}
onChange={(e) => {
handleHourChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleHourBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleHourKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
<input
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={minute}
onChange={(e) => {
handleMinuteChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleMinuteBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleMinuteKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<div
className={cn(
'ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]',
!timeWasSet && 'opacity-50'
)}
>
{(['AM', 'PM'] as const).map((period) => (
<button
key={period}
type='button'
onClick={() => handleAmpmChange(period)}
className={cn(
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
timeWasSet && ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
)}
>
{period}
</button>
))}
</div>
</div>
)}
{/* Today Button (only shown when time picker is not enabled) */}
{!showTime && (
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
)}
</>
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ export const knowledgeKeys = {
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
tagDefinitions: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
tagUsage: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
documents: (knowledgeBaseId: string, paramsKey: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
document: (knowledgeBaseId: string, documentId: string) =>
@@ -910,6 +912,38 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) {
})
}
export interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
export async function fetchTagUsage(knowledgeBaseId: string): Promise<TagUsageData[]> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error(`Failed to fetch tag usage: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch tag usage')
}
return Array.isArray(result.data) ? result.data : []
}
export function useTagUsageQuery(knowledgeBaseId?: string | null) {
return useQuery({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''),
queryFn: () => fetchTagUsage(knowledgeBaseId as string),
enabled: Boolean(knowledgeBaseId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
export interface CreateTagDefinitionParams {
knowledgeBaseId: string
displayName: string
@@ -968,6 +1002,9 @@ export function useCreateTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}
@@ -1006,6 +1043,9 @@ export function useDeleteTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}

View File

@@ -15,6 +15,8 @@ export const logKeys = {
lists: () => [...logKeys.all, 'list'] as const,
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
[...logKeys.lists(), workspaceId ?? '', filters] as const,
recent: (workspaceId: string | undefined, limit: number) =>
[...logKeys.all, 'recent', workspaceId ?? '', limit] as const,
details: () => [...logKeys.all, 'detail'] as const,
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
stats: (workspaceId: string | undefined, filters: object) =>
@@ -248,3 +250,57 @@ export function useExecutionSnapshot(executionId: string | undefined) {
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
})
}
/**
* Simple recent logs data for lightweight use cases (e.g., mention suggestions)
*/
export interface RecentLog {
id: string
executionId?: string
level: string
trigger: string | null
createdAt: string
workflow?: {
name?: string
title?: string
}
workflowName?: string
}
async function fetchRecentLogs(workspaceId: string, limit: number): Promise<RecentLog[]> {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
params.set('limit', limit.toString())
params.set('details', 'full')
const response = await fetch(`/api/logs?${params.toString()}`)
if (!response.ok) {
throw new Error('Failed to fetch recent logs')
}
const data = await response.json()
return Array.isArray(data?.data) ? data.data : []
}
interface UseRecentLogsOptions {
enabled?: boolean
}
/**
* Hook for fetching recent logs with minimal filtering.
* Useful for lightweight use cases like mention suggestions.
*/
export function useRecentLogs(
workspaceId: string | undefined,
limit = 50,
options?: UseRecentLogsOptions
) {
return useQuery({
queryKey: logKeys.recent(workspaceId, limit),
queryFn: () => fetchRecentLogs(workspaceId as string, limit),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,7 +103,7 @@ export function getPlaceholderForFieldType(fieldType: string): string {
case 'number':
return 'Enter number'
case 'date':
return 'YYYY-MM-DD'
return 'YYYY-MM-DD or YYYY-MM-DD HH:mm'
default:
return 'Enter value'
}

View File

@@ -0,0 +1,187 @@
/**
* Tests for knowledge tag validation utility functions
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseBooleanValue, parseDateValue, parseNumberValue, validateTagValue } from './utils'
describe('Knowledge Tag Utils', () => {
describe('validateTagValue', () => {
describe('boolean validation', () => {
it('should accept "true" as valid boolean', () => {
expect(validateTagValue('isActive', 'true', 'boolean')).toBeNull()
})
it('should accept "false" as valid boolean', () => {
expect(validateTagValue('isActive', 'false', 'boolean')).toBeNull()
})
it('should accept case-insensitive boolean values', () => {
expect(validateTagValue('isActive', 'TRUE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'FALSE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'True', 'boolean')).toBeNull()
})
it('should reject invalid boolean values', () => {
const result = validateTagValue('isActive', 'yes', 'boolean')
expect(result).toContain('expects a boolean value')
})
})
describe('number validation', () => {
it('should accept valid integers', () => {
expect(validateTagValue('count', '42', 'number')).toBeNull()
expect(validateTagValue('count', '-10', 'number')).toBeNull()
expect(validateTagValue('count', '0', 'number')).toBeNull()
})
it('should accept valid decimals', () => {
expect(validateTagValue('price', '19.99', 'number')).toBeNull()
expect(validateTagValue('price', '-3.14', 'number')).toBeNull()
})
it('should reject non-numeric values', () => {
const result = validateTagValue('count', 'abc', 'number')
expect(result).toContain('expects a number value')
})
})
describe('date validation', () => {
it('should accept valid YYYY-MM-DD format', () => {
expect(validateTagValue('createdAt', '2024-01-15', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-12-31', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp without timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T00:00:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T23:59:59', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with seconds omitted', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with UTC timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00Z', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with timezone offset', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00+05:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T14:30:00-08:00', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with milliseconds', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00.123Z', 'date')).toBeNull()
})
it('should reject invalid date format', () => {
const result = validateTagValue('createdAt', '01/15/2024', 'date')
expect(result).toContain('expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format')
})
it('should reject invalid date values like Feb 31', () => {
const result = validateTagValue('createdAt', '2024-02-31', 'date')
expect(result).toContain('invalid date')
})
it('should reject invalid time values', () => {
const result = validateTagValue('createdAt', '2024-01-15T25:00:00', 'date')
expect(result).toContain('invalid time')
})
it('should reject invalid minute values', () => {
const result = validateTagValue('createdAt', '2024-01-15T12:61:00', 'date')
expect(result).toContain('invalid time')
})
})
describe('text/default validation', () => {
it('should accept any string for text type', () => {
expect(validateTagValue('name', 'anything goes', 'text')).toBeNull()
expect(validateTagValue('name', '123', 'text')).toBeNull()
expect(validateTagValue('name', '', 'text')).toBeNull()
})
})
})
describe('parseDateValue', () => {
it('should parse valid YYYY-MM-DD format', () => {
const result = parseDateValue('2024-01-15')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0) // January is 0
expect(result?.getDate()).toBe(15)
})
it('should parse valid ISO 8601 timestamp', () => {
const result = parseDateValue('2024-01-15T14:30:00')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0)
expect(result?.getDate()).toBe(15)
expect(result?.getHours()).toBe(14)
expect(result?.getMinutes()).toBe(30)
})
it('should parse valid ISO 8601 timestamp with UTC timezone', () => {
const result = parseDateValue('2024-01-15T14:30:00Z')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
})
it('should return null for invalid format', () => {
expect(parseDateValue('01/15/2024')).toBeNull()
expect(parseDateValue('invalid')).toBeNull()
expect(parseDateValue('')).toBeNull()
})
it('should return null for invalid date values', () => {
expect(parseDateValue('2024-02-31')).toBeNull() // Feb 31 doesn't exist
expect(parseDateValue('2024-13-01')).toBeNull() // Month 13 doesn't exist
})
})
describe('parseNumberValue', () => {
it('should parse valid integers', () => {
expect(parseNumberValue('42')).toBe(42)
expect(parseNumberValue('-10')).toBe(-10)
expect(parseNumberValue('0')).toBe(0)
})
it('should parse valid decimals', () => {
expect(parseNumberValue('19.99')).toBe(19.99)
expect(parseNumberValue('-3.14')).toBeCloseTo(-3.14)
})
it('should return null for non-numeric strings', () => {
expect(parseNumberValue('abc')).toBeNull()
})
it('should return 0 for empty string (JavaScript Number behavior)', () => {
expect(parseNumberValue('')).toBe(0)
})
})
describe('parseBooleanValue', () => {
it('should parse "true" to true', () => {
expect(parseBooleanValue('true')).toBe(true)
expect(parseBooleanValue('TRUE')).toBe(true)
expect(parseBooleanValue(' true ')).toBe(true)
})
it('should parse "false" to false', () => {
expect(parseBooleanValue('false')).toBe(false)
expect(parseBooleanValue('FALSE')).toBe(false)
expect(parseBooleanValue(' false ')).toBe(false)
})
it('should return null for invalid values', () => {
expect(parseBooleanValue('yes')).toBeNull()
expect(parseBooleanValue('no')).toBeNull()
expect(parseBooleanValue('1')).toBeNull()
expect(parseBooleanValue('')).toBeNull()
})
})
})

View File

@@ -1,3 +1,14 @@
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
const ISO_WITH_TZ_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
/**
* Check if a string is a valid date format (YYYY-MM-DD or ISO 8601 timestamp)
*/
function isValidDateFormat(value: string): boolean {
return DATE_ONLY_REGEX.test(value) || DATETIME_REGEX.test(value) || ISO_WITH_TZ_REGEX.test(value)
}
/**
* Validate a tag value against its expected field type
* Returns an error message if invalid, or null if valid
@@ -21,16 +32,35 @@ export function validateTagValue(tagName: string, value: string, fieldType: stri
return null
}
case 'date': {
// Check format first
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
// Check format first - accept YYYY-MM-DD or ISO 8601 datetime
if (!isValidDateFormat(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format, but received "${value}"`
}
// Extract date parts for validation
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return `Tag "${tagName}" has an invalid date: "${value}"`
}
// If timestamp is included, validate time components
if (stringValue.includes('T')) {
const timePart = stringValue.split('T')[1]
// Extract hours and minutes, ignoring timezone
const timeMatch = timePart.match(/^(\d{2}):(\d{2})/)
if (timeMatch) {
const hours = Number.parseInt(timeMatch[1], 10)
const minutes = Number.parseInt(timeMatch[2], 10)
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return `Tag "${tagName}" has an invalid time: "${value}"`
}
}
}
return null
}
default:
@@ -56,25 +86,44 @@ export function parseNumberValue(value: string): number | null {
}
/**
* Parse a string to Date with strict YYYY-MM-DD validation
* Parse a string to Date with validation for YYYY-MM-DD or ISO 8601 timestamp
* Returns null if invalid format or invalid date
*/
export function parseDateValue(value: string): Date | null {
const stringValue = String(value).trim()
// Must be YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
// Must be valid date format
if (!isValidDateFormat(stringValue)) {
return null
}
// Extract date parts
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
// First check date-only validity
const testDate = new Date(year, month - 1, day)
if (
testDate.getFullYear() !== year ||
testDate.getMonth() !== month - 1 ||
testDate.getDate() !== day
) {
return null
}
return date
// If timestamp is included, parse with time
if (stringValue.includes('T')) {
// Use native Date parsing for ISO strings
const date = new Date(stringValue)
if (Number.isNaN(date.getTime())) {
return null
}
return date
}
// Date-only: return date at midnight local time
return new Date(year, month - 1, day)
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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