v0.3.50: debounce moved server side, hasWorkflowChanged fixes, advanced mode/serializer fix, jira fix, billing notifs

This commit is contained in:
Vikhyath Mondreti
2025-09-08 11:53:44 -07:00
committed by GitHub
73 changed files with 7570 additions and 1773 deletions

View File

@@ -58,7 +58,7 @@ Retrieve detailed information about a specific Jira issue
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `projectId` | string | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. |
| `projectId` | string | No | Jira project ID \(optional; not required to retrieve a single issue\). |
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |

View File

@@ -85,7 +85,8 @@ export async function POST(request: Request) {
logger.info(`Fetching all Discord channels for server: ${serverId}`)
// Fetch all channels from Discord API
// Listing guild channels with a bot token is allowed if the bot is in the guild.
// Keep the request, but if unauthorized, return an empty list so the selector doesn't hard fail.
const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}/channels`, {
method: 'GET',
headers: {
@@ -95,20 +96,14 @@ export async function POST(request: Request) {
})
if (!response.ok) {
logger.error('Discord API error:', {
status: response.status,
statusText: response.statusText,
})
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage = errorData.message || `Failed to fetch channels (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch channels: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
logger.warn(
'Discord API returned non-OK for channels; returning empty list to avoid UX break',
{
status: response.status,
statusText: response.statusText,
}
)
return NextResponse.json({ channels: [] })
}
const channels = (await response.json()) as DiscordChannel[]

View File

@@ -64,46 +64,14 @@ export async function POST(request: Request) {
})
}
// Otherwise, fetch all servers the bot is in
logger.info('Fetching all Discord servers')
const response = await fetch('https://discord.com/api/v10/users/@me/guilds', {
method: 'GET',
headers: {
Authorization: `Bot ${botToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
logger.error('Discord API error:', {
status: response.status,
statusText: response.statusText,
})
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage = errorData.message || `Failed to fetch servers (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch servers: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const servers = (await response.json()) as DiscordServer[]
logger.info(`Successfully fetched ${servers.length} servers`)
return NextResponse.json({
servers: servers.map((server: DiscordServer) => ({
id: server.id,
name: server.name,
icon: server.icon
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`
: null,
})),
})
// Listing guilds via REST requires a user OAuth2 access token with the 'guilds' scope.
// A bot token cannot call /users/@me/guilds and will return 401.
// Since this selector only has a bot token, return an empty list instead of erroring
// and let users provide a Server ID in advanced mode.
logger.info(
'Skipping guild listing: bot token cannot list /users/@me/guilds; returning empty list'
)
return NextResponse.json({ servers: [] })
} catch (error) {
logger.error('Error processing request:', error)
return NextResponse.json(

View File

@@ -6,17 +6,32 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraIssuesAPI')
// Helper functions
const createErrorResponse = async (response: Response, defaultMessage: string) => {
try {
const errorData = await response.json()
return errorData.message || errorData.errorMessages?.[0] || defaultMessage
} catch {
return defaultMessage
}
}
const validateRequiredParams = (domain: string | null, accessToken: string | null) => {
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
return null
}
export async function POST(request: Request) {
try {
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const validationError = validateRequiredParams(domain || null, accessToken || null)
if (validationError) return validationError
if (issueKeys.length === 0) {
logger.info('No issue keys provided, returning empty result')
@@ -24,7 +39,7 @@ export async function POST(request: Request) {
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
// Build the URL using cloudId for Jira API
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch`
@@ -53,47 +68,24 @@ export async function POST(request: Request) {
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', JSON.stringify(errorData, null, 2))
errorMessage = errorData.message || `Failed to fetch Jira issues (${response.status})`
} catch (e) {
logger.error('Could not parse error response as JSON:', e)
try {
const _text = await response.text()
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
} catch (_textError) {
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
}
}
const errorMessage = await createErrorResponse(
response,
`Failed to fetch Jira issues (${response.status})`
)
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const issues = (data.issues || []).map((issue: any) => ({
id: issue.key,
name: issue.fields.summary,
mimeType: 'jira/issue',
url: `https://${domain}/browse/${issue.key}`,
modifiedTime: issue.fields.updated,
webViewLink: `https://${domain}/browse/${issue.key}`,
}))
if (data.issues && data.issues.length > 0) {
data.issues.slice(0, 3).forEach((issue: any) => {
logger.info(`- ${issue.key}: ${issue.fields.summary}`)
})
}
return NextResponse.json({
issues: data.issues
? data.issues.map((issue: any) => ({
id: issue.key,
name: issue.fields.summary,
mimeType: 'jira/issue',
url: `https://${domain}/browse/${issue.key}`,
modifiedTime: issue.fields.updated,
webViewLink: `https://${domain}/browse/${issue.key}`,
}))
: [],
cloudId, // Return the cloudId so it can be cached
})
return NextResponse.json({ issues, cloudId })
} catch (error) {
logger.error('Error fetching Jira issues:', error)
return NextResponse.json(
@@ -111,83 +103,79 @@ export async function GET(request: Request) {
const providedCloudId = url.searchParams.get('cloudId')
const query = url.searchParams.get('query') || ''
const projectId = url.searchParams.get('projectId') || ''
const manualProjectId = url.searchParams.get('manualProjectId') || ''
const all = url.searchParams.get('all')?.toLowerCase() === 'true'
const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10)
const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId)
// Build query parameters
const params = new URLSearchParams()
// Only add query if it exists
if (query) {
params.append('query', query)
}
const validationError = validateRequiredParams(domain || null, accessToken || null)
if (validationError) return validationError
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
let data: any
if (query) {
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
const params = new URLSearchParams({ query })
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params}`
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage =
errorData.message || `Failed to fetch issue suggestions (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
}
const errorMessage = await createErrorResponse(
response,
`Failed to fetch issue suggestions (${response.status})`
)
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
data = await response.json()
} else if (projectId) {
// When no query, list latest issues for the selected project using Search API
const searchParams = new URLSearchParams()
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
searchParams.append('maxResults', '25')
searchParams.append('fields', 'summary,key')
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
const response = await fetch(searchUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
let errorMessage
try {
const errorData = await response.json()
logger.error('Jira Search API error details:', errorData)
errorMessage =
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
} else if (projectId || manualProjectId) {
const SAFETY_CAP = 1000
const PAGE_SIZE = 100
const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP)
const projectKey = (projectId || manualProjectId).trim()
const buildSearchUrl = (startAt: number) => {
const params = new URLSearchParams({
jql: `project=${projectKey} ORDER BY updated DESC`,
maxResults: String(Math.min(PAGE_SIZE, target)),
startAt: String(startAt),
fields: 'summary,key,updated',
})
return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}`
}
const searchData = await response.json()
const issues = (searchData.issues || []).map((it: any) => ({
let startAt = 0
let collected: any[] = []
let total = 0
do {
const response = await fetch(buildSearchUrl(startAt), {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorMessage = await createErrorResponse(
response,
`Failed to fetch issues (${response.status})`
)
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const page = await response.json()
const issues = page.issues || []
total = page.total || issues.length
collected = collected.concat(issues)
startAt += PAGE_SIZE
} while (all && collected.length < Math.min(total, target))
const issues = collected.slice(0, target).map((it: any) => ({
key: it.key,
summary: it.fields?.summary || it.key,
}))
@@ -196,10 +184,7 @@ export async function GET(request: Request) {
data = { sections: [], cloudId }
}
return NextResponse.json({
...data,
cloudId, // Return the cloudId so it can be cached
})
return NextResponse.json({ ...data, cloudId })
} catch (error) {
logger.error('Error fetching Jira issue suggestions:', error)
return NextResponse.json(

View File

@@ -42,10 +42,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
}
if (!issueType) {
logger.error('Missing issue type in request')
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
}
const normalizedIssueType = issueType || 'Task'
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
@@ -62,7 +59,7 @@ export async function POST(request: Request) {
id: projectId,
},
issuetype: {
name: issueType,
name: normalizedIssueType,
},
summary: summary,
}

View File

@@ -24,6 +24,7 @@ const SettingsSchema = z.object({
unsubscribeNotifications: z.boolean().optional(),
})
.optional(),
billingUsageNotificationsEnabled: z.boolean().optional(),
})
// Default settings values
@@ -35,6 +36,7 @@ const defaultSettings = {
consoleExpandedByDefault: true,
telemetryEnabled: true,
emailPreferences: {},
billingUsageNotificationsEnabled: true,
}
export async function GET() {
@@ -68,6 +70,7 @@ export async function GET() {
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
telemetryEnabled: userSettings.telemetryEnabled,
emailPreferences: userSettings.emailPreferences ?? {},
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
},
},
{ status: 200 }

View File

@@ -195,14 +195,31 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Process blocks
blocks.forEach((block) => {
const parentId = block.parentId || null
const extent = block.extent || null
const blockData = {
...(block.data || {}),
...(parentId && { parentId }),
...(extent && { extent }),
}
blocksMap[block.id] = {
id: block.id,
type: block.type,
name: block.name,
position: { x: Number(block.positionX), y: Number(block.positionY) },
data: block.data,
data: blockData,
enabled: block.enabled,
subBlocks: block.subBlocks || {},
// Preserve execution-relevant flags so serializer behavior matches manual runs
isWide: block.isWide ?? false,
advancedMode: block.advancedMode ?? false,
triggerMode: block.triggerMode ?? false,
outputs: block.outputs || {},
horizontalHandles: block.horizontalHandles ?? true,
height: Number(block.height || 0),
parentId,
extent,
}
})

View File

@@ -1,11 +1,9 @@
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { hasWorkflowChanged } from '@/lib/workflows/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
const logger = createLogger('WorkflowStatusAPI')
@@ -24,72 +22,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Check if the workflow has meaningful changes that would require redeployment
let needsRedeployment = false
if (validation.workflow.isDeployed && validation.workflow.deployedState) {
// Get current state from normalized tables (same logic as deployment API)
const blocks = await db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, id))
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id))
const subflows = await db
.select()
.from(workflowSubflows)
.where(eq(workflowSubflows.workflowId, id))
// Build current state from normalized data
const blocksMap: Record<string, any> = {}
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
// Process blocks
blocks.forEach((block) => {
blocksMap[block.id] = {
id: block.id,
type: block.type,
name: block.name,
position: { x: Number(block.positionX), y: Number(block.positionY) },
data: block.data,
enabled: block.enabled,
subBlocks: block.subBlocks || {},
}
})
// Process subflows (loops and parallels)
subflows.forEach((subflow) => {
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
loops[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterations: config.iterations || 1,
loopType: config.loopType || 'for',
forEachItems: config.forEachItems || '',
}
} else if (subflow.type === 'parallel') {
parallels[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
count: config.count || 2,
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
}
})
// Convert edges to the expected format
const edgesArray = edges.map((edge) => ({
id: edge.id,
source: edge.sourceBlockId,
target: edge.targetBlockId,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'default',
data: {},
}))
const normalizedData = await loadWorkflowFromNormalizedTables(id)
const currentState = {
blocks: blocksMap,
edges: edgesArray,
loops,
parallels,
blocks: normalizedData?.blocks || {},
edges: normalizedData?.edges || [],
loops: normalizedData?.loops || {},
parallels: normalizedData?.parallels || {},
lastSaved: Date.now(),
}

View File

@@ -45,6 +45,7 @@ import {
useKeyboardShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useFolderStore } from '@/stores/folders/store'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -258,17 +259,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
// Get current store state for change detection
const currentBlocks = useWorkflowStore((state) => state.blocks)
const currentEdges = useWorkflowStore((state) => state.edges)
const subBlockValues = useSubBlockStore((state) =>
activeWorkflowId ? state.workflowValues[activeWorkflowId] : null
)
useEffect(() => {
// Avoid off-by-one false positives: wait until operation queue is idle
const { operations, isProcessing } = useOperationQueueStore.getState()
const hasPendingOps =
isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing')
if (!activeWorkflowId || !deployedState) {
setChangeDetected(false)
return
}
if (isLoadingDeployedState) {
if (isLoadingDeployedState || hasPendingOps) {
return
}
@@ -291,7 +298,16 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
checkForChanges()
}, [activeWorkflowId, deployedState, currentBlocks, subBlockValues, isLoadingDeployedState])
}, [
activeWorkflowId,
deployedState,
currentBlocks,
currentEdges,
subBlockValues,
isLoadingDeployedState,
useOperationQueueStore.getState().isProcessing,
useOperationQueueStore.getState().operations.length,
])
useEffect(() => {
if (session?.user?.id && !isRegistryLoading) {

View File

@@ -1,322 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { DiscordIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DiscordChannelSelector')
export interface DiscordChannelInfo {
id: string
name: string
type: number
}
interface DiscordChannelSelectorProps {
value: string
onChange: (value: string, channelInfo?: DiscordChannelInfo) => void
botToken: string
serverId: string
label?: string
disabled?: boolean
showPreview?: boolean
onChannelInfoChange?: (info: DiscordChannelInfo | null) => void
}
export function DiscordChannelSelector({
value,
onChange,
botToken,
serverId,
label = 'Select Discord channel',
disabled = false,
showPreview = true,
onChannelInfoChange,
}: DiscordChannelSelectorProps) {
const [open, setOpen] = useState(false)
const [channels, setChannels] = useState<DiscordChannelInfo[]>([])
const [selectedChannelId, setSelectedChannelId] = useState(value)
const [selectedChannel, setSelectedChannel] = useState<DiscordChannelInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Fetch channels from Discord API
const fetchChannels = useCallback(async () => {
if (!botToken || !serverId) {
setError(!botToken ? 'Bot token is required' : 'Server ID is required')
return
}
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/discord/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken, serverId }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord channels')
}
const data = await response.json()
setChannels(data.channels || [])
// If we have a selected channel ID, find the channel info
const currentSelectedId = selectedChannelId // Store in local variable
if (currentSelectedId) {
const channelInfo = data.channels?.find(
(channel: DiscordChannelInfo) => channel.id === currentSelectedId
)
if (channelInfo) {
setSelectedChannel(channelInfo)
onChannelInfoChange?.(channelInfo)
}
}
} catch (error) {
logger.error('Error fetching channels:', error)
setError((error as Error).message)
setChannels([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [botToken, serverId, selectedChannelId, onChannelInfoChange])
// Handle open change - only fetch channels when the dropdown is opened
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch channels when opening the dropdown and if we have valid token and server
if (isOpen && botToken && serverId && (!initialFetchDone || channels.length === 0)) {
fetchChannels()
}
}
// Fetch only the selected channel info when component mounts or when selectedChannelId changes
// This is more efficient than fetching all channels
const fetchSelectedChannelInfo = useCallback(async () => {
if (!botToken || !serverId || !selectedChannelId) return
setIsLoading(true)
setError(null)
try {
// Only fetch the specific channel by ID instead of all channels
const response = await fetch('/api/tools/discord/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
botToken,
serverId,
channelId: selectedChannelId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord channel')
}
const data = await response.json()
if (data.channel) {
setSelectedChannel(data.channel)
onChannelInfoChange?.(data.channel)
} else if (data.channels && data.channels.length > 0) {
const channelInfo = data.channels.find(
(channel: DiscordChannelInfo) => channel.id === selectedChannelId
)
if (channelInfo) {
setSelectedChannel(channelInfo)
onChannelInfoChange?.(channelInfo)
}
}
} catch (error) {
logger.error('Error fetching channel info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
}, [botToken, serverId, selectedChannelId, onChannelInfoChange])
// Fetch selected channel info when component mounts or dependencies change
useEffect(() => {
if (value && botToken && serverId && (!selectedChannel || selectedChannel.id !== value)) {
fetchSelectedChannelInfo()
}
}, [value, botToken, serverId, selectedChannel, fetchSelectedChannelInfo])
// Sync with external value
useEffect(() => {
if (value !== selectedChannelId) {
setSelectedChannelId(value)
// Find channel info for the new value
if (value && channels.length > 0) {
const channelInfo = channels.find((channel) => channel.id === value)
setSelectedChannel(channelInfo || null)
onChannelInfoChange?.(channelInfo || null)
} else if (value) {
// If we have a value but no channel info, we might need to fetch it
if (!selectedChannel || selectedChannel.id !== value) {
fetchSelectedChannelInfo()
}
} else {
setSelectedChannel(null)
onChannelInfoChange?.(null)
}
}
}, [
value,
channels,
selectedChannelId,
selectedChannel,
fetchSelectedChannelInfo,
onChannelInfoChange,
])
// Handle channel selection
const handleSelectChannel = (channel: DiscordChannelInfo) => {
setSelectedChannelId(channel.id)
setSelectedChannel(channel)
onChange(channel.id, channel)
onChannelInfoChange?.(channel)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedChannelId('')
setSelectedChannel(null)
onChange('', undefined)
onChannelInfoChange?.(null)
setError(null)
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !botToken || !serverId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedChannel ? (
<>
<span className='text-muted-foreground'>#</span>
<span className='truncate font-normal'>{selectedChannel.name}</span>
</>
) : (
<>
<DiscordIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search channels...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading channels...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : channels.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No channels found</p>
<p className='text-muted-foreground text-xs'>
The bot needs access to view channels in this server
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching channels</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Channels
</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.name}`}
onSelect={() => handleSelectChannel(channel)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='text-muted-foreground'>#</span>
<span className='truncate font-normal'>{channel.name}</span>
</div>
{channel.id === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Channel preview */}
{showPreview && selectedChannel && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-muted/20'>
<span className='font-semibold text-muted-foreground'>#</span>
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>{selectedChannel.name}</h4>
<div className='text-muted-foreground text-xs'>Channel ID: {selectedChannel.id}</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,5 @@
export type { ConfluenceFileInfo } from './confluence-file-selector'
export { ConfluenceFileSelector } from './confluence-file-selector'
export type { DiscordChannelInfo } from './discord-channel-selector'
export { DiscordChannelSelector } from './discord-channel-selector'
export type { GoogleCalendarInfo } from './google-calendar-selector'
export { GoogleCalendarSelector } from './google-calendar-selector'
export type { FileInfo } from './google-drive-picker'

View File

@@ -6,7 +6,6 @@ import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
DiscordChannelSelector,
GoogleCalendarSelector,
GoogleDrivePicker,
JiraIssueSelector,
@@ -70,8 +69,6 @@ export function FileSelectorInput({
const [planIdValue] = useSubBlockValue(blockId, 'planId')
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
const [operationValue] = useSubBlockValue(blockId, 'operation')
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
@@ -87,7 +84,6 @@ export function FileSelectorInput({
const provider = subBlock.provider || 'google-drive'
const isConfluence = provider === 'confluence'
const isJira = provider === 'jira'
const isDiscord = provider === 'discord'
const isMicrosoftTeams = provider === 'microsoft-teams'
const isMicrosoftExcel = provider === 'microsoft-excel'
const isMicrosoftWord = provider === 'microsoft-word'
@@ -108,13 +104,7 @@ export function FileSelectorInput({
''
: ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
: ''
const serverId = isDiscord
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
: ''
// Discord channel selector removed; no special values used here
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
@@ -154,31 +144,6 @@ export function FileSelectorInput({
)
}
// Render Discord channel selector
if (isDiscord) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordChannelSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(channelId) => setStoreValue(channelId)}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={finalDisabled}
showPreview={true}
/>
</div>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
)
}
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (connectedCredential as string) || ''

View File

@@ -14,7 +14,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('LongInput')
@@ -382,11 +381,6 @@ export function LongInput({
onScroll={handleScroll}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
onBlur={() => {
try {
useOperationQueueStore.getState().flushDebouncedForBlock(blockId)
} catch {}
}}
onFocus={() => {
setShowEnvVars(false)
setShowTags(false)

View File

@@ -1,324 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { DiscordIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DiscordServerSelector')
export interface DiscordServerInfo {
id: string
name: string
icon?: string | null
}
interface DiscordServerSelectorProps {
value: string
onChange: (value: string, serverInfo?: DiscordServerInfo) => void
botToken: string
label?: string
disabled?: boolean
showPreview?: boolean
}
export function DiscordServerSelector({
value,
onChange,
botToken,
label = 'Select Discord server',
disabled = false,
showPreview = true,
}: DiscordServerSelectorProps) {
const [open, setOpen] = useState(false)
const [servers, setServers] = useState<DiscordServerInfo[]>([])
const [selectedServerId, setSelectedServerId] = useState(value)
const [selectedServer, setSelectedServer] = useState<DiscordServerInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Fetch servers from Discord API
const fetchServers = useCallback(async () => {
if (!botToken) {
setError('Bot token is required')
return
}
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/discord/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord servers')
}
const data = await response.json()
setServers(data.servers || [])
// If we have a selected server ID, find the server info
if (selectedServerId) {
const serverInfo = data.servers?.find(
(server: DiscordServerInfo) => server.id === selectedServerId
)
if (serverInfo) {
setSelectedServer(serverInfo)
}
}
} catch (error) {
logger.error('Error fetching servers:', error)
setError((error as Error).message)
setServers([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [botToken, selectedServerId])
// Handle open change - only fetch servers when the dropdown is opened
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch servers when opening the dropdown and if we have a valid token
if (isOpen && botToken && (!initialFetchDone || servers.length === 0)) {
fetchServers()
}
}
// Fetch only the selected server info when component mounts or when selectedServerId changes
// This is more efficient than fetching all servers
const fetchSelectedServerInfo = useCallback(async () => {
if (!botToken || !selectedServerId) return
setIsLoading(true)
setError(null)
try {
// Only fetch the specific server by ID instead of all servers
const response = await fetch('/api/tools/discord/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
botToken,
serverId: selectedServerId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord server')
}
const data = await response.json()
if (data.server) {
setSelectedServer(data.server)
} else if (data.servers && data.servers.length > 0) {
const serverInfo = data.servers.find(
(server: DiscordServerInfo) => server.id === selectedServerId
)
if (serverInfo) {
setSelectedServer(serverInfo)
}
}
} catch (error) {
logger.error('Error fetching server info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
}, [botToken, selectedServerId])
// Fetch selected server info when component mounts or selectedServerId changes
useEffect(() => {
if (value && botToken && (!selectedServer || selectedServer.id !== value)) {
fetchSelectedServerInfo()
}
}, [value, botToken, selectedServer, fetchSelectedServerInfo])
// Sync with external value
useEffect(() => {
if (value !== selectedServerId) {
setSelectedServerId(value)
// Find server info for the new value
if (value && servers.length > 0) {
const serverInfo = servers.find((server) => server.id === value)
setSelectedServer(serverInfo || null)
} else if (value) {
// If we have a value but no server info, we might need to fetch it
if (!selectedServer || selectedServer.id !== value) {
fetchSelectedServerInfo()
}
} else {
setSelectedServer(null)
}
}
}, [value, servers, selectedServerId, selectedServer, fetchSelectedServerInfo])
// Handle server selection
const handleSelectServer = (server: DiscordServerInfo) => {
setSelectedServerId(server.id)
setSelectedServer(server)
onChange(server.id, server)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedServerId('')
setSelectedServer(null)
onChange('', undefined)
setError(null)
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !botToken}
>
{selectedServer ? (
<div className='flex items-center gap-2 overflow-hidden'>
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{selectedServer.name}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<DiscordIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search servers...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading servers...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : servers.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No servers found</p>
<p className='text-muted-foreground text-xs'>
Make sure your bot is added to at least one server
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching servers</p>
</div>
)}
</CommandEmpty>
{servers.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Servers
</div>
{servers.map((server) => (
<CommandItem
key={server.id}
value={`server-${server.id}-${server.name}`}
onSelect={() => handleSelectServer(server)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
{server.icon ? (
<img
src={server.icon}
alt={server.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{server.name}</span>
</div>
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Server preview */}
{showPreview && selectedServer && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-muted/20'>
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>{selectedServer.name}</h4>
<div className='text-muted-foreground text-xs'>Server ID: {selectedServer.id}</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -2,10 +2,6 @@
import { useEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
type DiscordServerInfo,
DiscordServerSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector'
import {
type JiraProjectInfo,
JiraProjectSelector,
@@ -44,7 +40,7 @@ export function ProjectSelectorInput({
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
@@ -60,14 +56,12 @@ export function ProjectSelectorInput({
// Get provider-specific values
const provider = subBlock.provider || 'jira'
const isDiscord = provider === 'discord'
const isLinear = provider === 'linear'
// Jira/Discord upstream fields
const [jiraDomain] = useSubBlockValue(blockId, 'domain')
const [jiraCredential] = useSubBlockValue(blockId, 'credential')
const domain = (jiraDomain as string) || ''
const botToken = ''
// Verify Jira credential belongs to current user; if not, treat as absent
@@ -85,7 +79,7 @@ export function ProjectSelectorInput({
// Handle project selection
const handleProjectChange = (
projectId: string,
info?: JiraProjectInfo | DiscordServerInfo | LinearTeamInfo | LinearProjectInfo
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
) => {
setSelectedProjectId(projectId)
setProjectInfo(info || null)
@@ -94,34 +88,7 @@ export function ProjectSelectorInput({
onProjectSelect?.(projectId)
}
// Render Discord server selector if provider is discord
if (isDiscord) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordServerSelector
value={selectedProjectId}
onChange={(serverId: string, serverInfo?: DiscordServerInfo) => {
handleProjectChange(serverId, serverInfo)
}}
botToken={botToken}
label={subBlock.placeholder || 'Select Discord server'}
disabled={disabled || !botToken}
showPreview={true}
/>
</div>
</TooltipTrigger>
{!botToken && (
<TooltipContent side='top'>
<p>Please enter a Bot Token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Discord no longer uses a server selector; fall through to other providers
// Render Linear team/project selector if provider is linear
if (isLinear) {

View File

@@ -14,7 +14,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('ShortInput')
@@ -396,9 +395,6 @@ export function ShortInput({
onBlur={() => {
setIsFocused(false)
setShowEnvVars(false)
try {
useOperationQueueStore.getState().flushDebouncedForBlock(blockId)
} catch {}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}

View File

@@ -18,7 +18,7 @@ interface UseSubBlockValueOptions {
/**
* Custom hook to get and set values for a sub-block in a workflow.
* Handles complex object values properly by using deep equality comparison.
* Includes automatic debouncing and explicit streaming mode for AI generation.
* Supports explicit streaming mode for AI generation.
*
* @param blockId The ID of the block containing the sub-block
* @param subBlockId The ID of the sub-block
@@ -181,7 +181,7 @@ export function useSubBlockValue<T = any>(
}
}
// Emit immediately - let the operation queue handle debouncing and deduplication
// Emit immediately; the client queue coalesces same-key ops and the server debounces
emitValue(valueCopy)
if (triggerWorkflowUpdate) {

View File

@@ -1,7 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Skeleton } from '@/components/ui'
import { Skeleton, Switch } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
import { cn } from '@/lib/utils'
@@ -500,6 +499,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)}
{/* Billing usage notifications toggle */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{subscription.isEnterprise && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
@@ -527,3 +529,42 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)
}
function BillingUsageNotificationsToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null)
useEffect(() => {
let isMounted = true
const load = async () => {
const res = await fetch('/api/users/me/settings')
const json = await res.json()
const current = json?.data?.billingUsageNotificationsEnabled
if (isMounted) setEnabled(current !== false)
}
load()
return () => {
isMounted = false
}
}, [])
const update = async (next: boolean) => {
setEnabled(next)
await fetch('/api/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingUsageNotificationsEnabled: next }),
})
}
if (enabled === null) return null
return (
<div className='mt-4 flex items-center justify-between'>
<div className='flex flex-col'>
<span className='font-medium text-sm'>Usage notifications</span>
<span className='text-muted-foreground text-xs'>Email me when I reach 80% usage</span>
</div>
<Switch checked={enabled} onCheckedChange={(v: boolean) => update(v)} />
</div>
)
}

View File

@@ -154,7 +154,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
// Construct parameters based on operation
const baseParams = {
accessToken: credential,
credential,
...rest,
}

View File

@@ -55,6 +55,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
title: 'Select Page',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'pageId',
provider: 'confluence',
serviceId: 'confluence',
placeholder: 'Select Confluence page',
@@ -67,6 +68,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
title: 'Page ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'pageId',
placeholder: 'Enter Confluence page ID',
mode: 'advanced',
},
@@ -112,7 +114,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
}
return {
accessToken: credential,
credential,
pageId: effectivePageId,
...rest,
}

View File

@@ -34,56 +34,30 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
password: true,
required: true,
},
// Server selector (basic mode)
{
id: 'serverId',
title: 'Server',
type: 'project-selector',
layout: 'full',
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord server',
dependsOn: ['botToken'],
mode: 'basic',
condition: {
field: 'operation',
value: ['discord_send_message', 'discord_get_messages', 'discord_get_server'],
},
},
// Manual server ID input (advanced mode)
{
id: 'manualServerId',
title: 'Server ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Discord server ID',
mode: 'advanced',
required: true,
provider: 'discord',
serviceId: 'discord',
condition: {
field: 'operation',
value: ['discord_send_message', 'discord_get_messages', 'discord_get_server'],
},
},
// Channel selector (basic mode)
// Channel ID (single input used in all modes)
{
id: 'channelId',
title: 'Channel',
type: 'file-selector',
layout: 'full',
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord channel',
dependsOn: ['botToken', 'serverId'],
mode: 'basic',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},
// Manual channel ID input (advanced mode)
{
id: 'manualChannelId',
title: 'Channel ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Discord channel ID',
mode: 'advanced',
required: true,
provider: 'discord',
serviceId: 'discord',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},
{
@@ -139,56 +113,44 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
if (!params.botToken) throw new Error('Bot token required for this operation')
commonParams.botToken = params.botToken
// Handle server ID (selector or manual)
const effectiveServerId = (params.serverId || params.manualServerId || '').trim()
// Handle channel ID (selector or manual)
const effectiveChannelId = (params.channelId || params.manualChannelId || '').trim()
// Single inputs
const serverId = (params.serverId || '').trim()
const channelId = (params.channelId || '').trim()
switch (params.operation) {
case 'discord_send_message':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required. Please select a channel or enter a channel ID manually.'
)
if (!channelId) {
throw new Error('Channel ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
channelId: effectiveChannelId,
serverId,
channelId,
content: params.content,
}
case 'discord_get_messages':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required. Please select a channel or enter a channel ID manually.'
)
if (!channelId) {
throw new Error('Channel ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
channelId: effectiveChannelId,
serverId,
channelId,
limit: params.limit ? Math.min(Math.max(1, Number(params.limit)), 100) : 10,
}
case 'discord_get_server':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
serverId,
}
case 'discord_get_user':
return {
@@ -205,9 +167,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
operation: { type: 'string', description: 'Operation to perform' },
botToken: { type: 'string', description: 'Discord bot token' },
serverId: { type: 'string', description: 'Discord server identifier' },
manualServerId: { type: 'string', description: 'Manual server identifier' },
channelId: { type: 'string', description: 'Discord channel identifier' },
manualChannelId: { type: 'string', description: 'Manual channel identifier' },
content: { type: 'string', description: 'Message content' },
limit: { type: 'number', description: 'Message limit' },
userId: { type: 'string', description: 'Discord user identifier' },

View File

@@ -99,6 +99,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
title: 'Label',
type: 'folder-selector',
layout: 'full',
canonicalParamId: 'folder',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
@@ -116,6 +117,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
title: 'Label/Folder',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folder',
placeholder: 'Enter Gmail label name (e.g., INBOX, SENT, or custom label)',
mode: 'advanced',
condition: { field: 'operation', value: 'read_gmail' },
@@ -195,13 +197,11 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
}
},
params: (params) => {
// Pass the credential directly from the credential field
const { credential, folder, manualFolder, ...rest } = params
// Handle folder input (selector or manual)
// Handle both selector and manual folder input
const effectiveFolder = (folder || manualFolder || '').trim()
// Ensure folder is always provided for read_gmail operation
if (rest.operation === 'read_gmail') {
rest.folder = effectiveFolder || 'INBOX'
}

View File

@@ -44,6 +44,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
title: 'Calendar',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'calendarId',
provider: 'google-calendar',
serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
@@ -57,6 +58,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
title: 'Calendar ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'calendarId',
placeholder: 'Enter calendar ID (e.g., primary or calendar@gmail.com)',
mode: 'advanced',
},
@@ -269,7 +271,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
}
return {
accessToken: credential,
credential,
...processedParams,
}
},

View File

@@ -44,6 +44,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Select Document',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'documentId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Document ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'documentId',
placeholder: 'Enter document ID',
dependsOn: ['credential'],
mode: 'advanced',
@@ -80,6 +82,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -95,6 +98,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -139,13 +143,14 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
const { credential, documentId, manualDocumentId, folderSelector, folderId, ...rest } =
params
// Handle both selector and manual inputs
const effectiveDocumentId = (documentId || manualDocumentId || '').trim()
const effectiveFolderId = (folderSelector || folderId || '').trim()
return {
...rest,
documentId: effectiveDocumentId,
folderId: effectiveFolderId,
documentId: effectiveDocumentId || undefined,
folderId: effectiveFolderId || undefined,
credential,
}
},

View File

@@ -76,6 +76,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -90,6 +91,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'upload' },
@@ -150,6 +152,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -165,6 +168,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'create_folder' },
@@ -175,6 +179,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -190,6 +195,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
@@ -233,8 +239,8 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
const effectiveFolderId = (folderSelector || manualFolderId || '').trim()
return {
accessToken: credential,
folderId: effectiveFolderId,
credential,
folderId: effectiveFolderId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
...rest,

View File

@@ -45,6 +45,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'spreadsheetId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
title: 'Spreadsheet ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'spreadsheetId',
placeholder: 'ID of the spreadsheet (from URL)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -174,18 +176,13 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
params: (params) => {
const { credential, values, spreadsheetId, manualSpreadsheetId, ...rest } = params
// Parse values from JSON string to array if it exists
const parsedValues = values ? JSON.parse(values as string) : undefined
// Use the selected spreadsheet ID or the manually entered one
// If spreadsheetId is provided, it's from the file selector and contains the file ID
// If not, fall back to manually entered ID
// Handle both selector and manual input
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
if (!effectiveSpreadsheetId) {
throw new Error(
'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.'
)
throw new Error('Spreadsheet ID is required.')
}
return {

View File

@@ -20,7 +20,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
layout: 'full',
options: [
{ label: 'Read Issue', id: 'read' },
{ label: 'Read Issues', id: 'read-bulk' },
{ label: 'Update Issue', id: 'update' },
{ label: 'Write Issue', id: 'write' },
],
@@ -59,6 +58,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Select Project',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'projectId',
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira project',
@@ -71,6 +71,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Project ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'projectId',
placeholder: 'Enter Jira project ID',
dependsOn: ['credential', 'domain'],
mode: 'advanced',
@@ -81,6 +82,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Select Issue',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'issueKey',
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira issue',
@@ -94,8 +96,9 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Issue Key',
type: 'short-input',
layout: 'full',
canonicalParamId: 'issueKey',
placeholder: 'Enter Jira issue key',
dependsOn: ['credential', 'domain', 'projectId'],
dependsOn: ['credential', 'domain', 'projectId', 'manualProjectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'advanced',
},
@@ -123,8 +126,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
access: ['jira_retrieve', 'jira_update', 'jira_write', 'jira_bulk_read'],
config: {
tool: (params) => {
const effectiveProjectId = (params.projectId || params.manualProjectId || '').trim()
const effectiveIssueKey = (params.issueKey || params.manualIssueKey || '').trim()
switch (params.operation) {
case 'read':
// If a project is selected but no issue is chosen, route to bulk read
if (effectiveProjectId && !effectiveIssueKey) {
return 'jira_bulk_read'
}
return 'jira_retrieve'
case 'update':
return 'jira_update'
@@ -139,19 +149,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
params: (params) => {
const { credential, projectId, manualProjectId, issueKey, manualIssueKey, ...rest } = params
// Base params that are always needed
// Use the selected IDs or the manually entered ones
const effectiveProjectId = (projectId || manualProjectId || '').trim()
const effectiveIssueKey = (issueKey || manualIssueKey || '').trim()
const baseParams = {
accessToken: credential,
credential,
domain: params.domain,
}
// Use the selected project ID or the manually entered one
const effectiveProjectId = (projectId || manualProjectId || '').trim()
// Use the selected issue key or the manually entered one
const effectiveIssueKey = (issueKey || manualIssueKey || '').trim()
// Define allowed parameters for each operation
switch (params.operation) {
case 'write': {
if (!effectiveProjectId) {
@@ -159,8 +165,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'Project ID is required. Please select a project or enter a project ID manually.'
)
}
// For write operations, only include write-specific fields
const writeParams = {
projectId: effectiveProjectId,
summary: params.summary || '',
@@ -168,7 +172,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
issueType: params.issueType || 'Task',
parent: params.parentIssue ? { key: params.parentIssue } : undefined,
}
return {
...baseParams,
...writeParams,
@@ -185,44 +188,46 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'Issue Key is required. Please select an issue or enter an issue key manually.'
)
}
// For update operations, only include update-specific fields
const updateParams = {
projectId: effectiveProjectId,
issueKey: effectiveIssueKey,
summary: params.summary || '',
description: params.description || '',
}
return {
...baseParams,
...updateParams,
}
}
case 'read': {
if (!effectiveIssueKey) {
// Check for project ID from either source
const projectForRead = (params.projectId || params.manualProjectId || '').trim()
const issueForRead = (params.issueKey || params.manualIssueKey || '').trim()
if (!issueForRead) {
throw new Error(
'Issue Key is required. Please select an issue or enter an issue key manually.'
'Select a project to read issues, or provide an issue key to read a single issue.'
)
}
// For read operations, only include read-specific fields
return {
...baseParams,
issueKey: effectiveIssueKey,
issueKey: issueForRead,
// Include projectId if available for context
...(projectForRead && { projectId: projectForRead }),
}
}
case 'read-bulk': {
if (!effectiveProjectId) {
// Check both projectId and manualProjectId directly from params
const finalProjectId = params.projectId || params.manualProjectId || ''
if (!finalProjectId) {
throw new Error(
'Project ID is required. Please select a project or enter a project ID manually.'
)
}
// For read-bulk operations, only include read-bulk-specific fields
return {
...baseParams,
projectId: effectiveProjectId,
projectId: finalProjectId.trim(),
}
}
default:

View File

@@ -1,7 +1,9 @@
import { LinearIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { BlockConfig, BlockIcon } from '@/blocks/types'
import type { LinearResponse } from '@/tools/linear/types'
const LinearBlockIcon: BlockIcon = (props) => LinearIcon(props as any)
export const LinearBlock: BlockConfig<LinearResponse> = {
type: 'linear',
name: 'Linear',
@@ -9,7 +11,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
longDescription:
'Integrate with Linear to fetch, filter, and create issues directly from your workflow.',
category: 'tools',
icon: LinearIcon,
icon: LinearBlockIcon,
bgColor: '#5E6AD2',
subBlocks: [
{
@@ -39,6 +41,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Team',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'teamId',
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a team',
@@ -50,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Project',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'projectId',
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a project',
@@ -62,6 +66,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Team ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'teamId',
placeholder: 'Enter Linear team ID',
mode: 'advanced',
},
@@ -71,6 +76,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Project ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'projectId',
placeholder: 'Enter Linear project ID',
mode: 'advanced',
},
@@ -96,19 +102,15 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
tool: (params) =>
params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues',
params: (params) => {
// Handle team ID (selector or manual)
// Handle both selector and manual inputs
const effectiveTeamId = (params.teamId || params.manualTeamId || '').trim()
// Handle project ID (selector or manual)
const effectiveProjectId = (params.projectId || params.manualProjectId || '').trim()
if (!effectiveTeamId) {
throw new Error('Team ID is required. Please select a team or enter a team ID manually.')
throw new Error('Team ID is required.')
}
if (!effectiveProjectId) {
throw new Error(
'Project ID is required. Please select a project or enter a project ID manually.'
)
throw new Error('Project ID is required.')
}
if (params.operation === 'write') {

View File

@@ -41,6 +41,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'spreadsheetId',
provider: 'microsoft-excel',
serviceId: 'microsoft-excel',
requiredScopes: [],
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
title: 'Spreadsheet ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'spreadsheetId',
placeholder: 'Enter spreadsheet ID',
dependsOn: ['credential'],
mode: 'advanced',
@@ -147,6 +149,9 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
const { credential, values, spreadsheetId, manualSpreadsheetId, tableName, ...rest } =
params
// Handle both selector and manual input
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
// Parse values from JSON string to array if it exists
let parsedValues
try {
@@ -155,13 +160,8 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
throw new Error('Invalid JSON format for values')
}
// Use the selected spreadsheet ID or the manually entered one
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
if (!effectiveSpreadsheetId) {
throw new Error(
'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.'
)
throw new Error('Spreadsheet ID is required.')
}
// For table operations, ensure tableName is provided

View File

@@ -73,11 +73,12 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'basic',
canonicalParamId: 'taskId',
},
// Advanced mode
{
id: 'taskId',
id: 'manualTaskId',
title: 'Manual Task ID',
type: 'short-input',
layout: 'full',
@@ -85,6 +86,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'advanced',
canonicalParamId: 'taskId',
},
{
@@ -147,6 +149,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
operation,
planId,
taskId,
manualTaskId,
title,
description,
dueDateTime,
@@ -160,13 +163,16 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
credential,
}
// Handle both selector and manual task ID
const effectiveTaskId = (taskId || manualTaskId || '').trim()
// For read operations
if (operation === 'read_task') {
const readParams: MicrosoftPlannerBlockParams = { ...baseParams }
// If taskId is provided, add it (highest priority - get specific task)
if (taskId?.trim()) {
readParams.taskId = taskId.trim()
if (effectiveTaskId) {
readParams.taskId = effectiveTaskId
}
// If no taskId but planId is provided, add planId (get tasks from plan)
else if (planId?.trim()) {
@@ -220,6 +226,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
credential: { type: 'string', description: 'Microsoft account credential' },
planId: { type: 'string', description: 'Plan ID' },
taskId: { type: 'string', description: 'Task ID' },
manualTaskId: { type: 'string', description: 'Manual Task ID' },
title: { type: 'string', description: 'Task title' },
description: { type: 'string', description: 'Task description' },
dueDateTime: { type: 'string', description: 'Due date' },

View File

@@ -57,6 +57,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Team',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'teamId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -70,6 +71,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Team ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'teamId',
placeholder: 'Enter team ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
@@ -79,6 +81,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Chat',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'chatId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -92,6 +95,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Chat ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'chatId',
placeholder: 'Enter chat ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
@@ -101,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Channel',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'channelId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -114,6 +119,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Channel ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'channelId',
placeholder: 'Enter channel ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
@@ -177,42 +183,27 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
const effectiveChatId = (chatId || manualChatId || '').trim()
const effectiveChannelId = (channelId || manualChannelId || '').trim()
// Build the parameters based on operation type
const baseParams = {
...rest,
credential,
}
// For chat operations, we need chatId
if (operation === 'read_chat' || operation === 'write_chat') {
// Don't pass empty chatId - let the tool handle the error
if (!effectiveChatId) {
throw new Error(
'Chat ID is required for chat operations. Please select a chat or enter a chat ID manually.'
)
}
return {
...baseParams,
chatId: effectiveChatId,
throw new Error('Chat ID is required. Please select a chat or enter a chat ID.')
}
return { ...baseParams, chatId: effectiveChatId }
}
// For channel operations, we need teamId and channelId
if (operation === 'read_channel' || operation === 'write_channel') {
if (!effectiveTeamId) {
throw new Error(
'Team ID is required for channel operations. Please select a team or enter a team ID manually.'
)
throw new Error('Team ID is required for channel operations.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required for channel operations. Please select a channel or enter a channel ID manually.'
)
}
return {
...baseParams,
teamId: effectiveTeamId,
channelId: effectiveChannelId,
throw new Error('Channel ID is required for channel operations.')
}
return { ...baseParams, teamId: effectiveTeamId, channelId: effectiveChannelId }
}
return baseParams

View File

@@ -282,7 +282,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
return {
...rest,
accessToken: credential,
credential,
...(parsedProperties ? { properties: parsedProperties } : {}),
...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}),
...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}),

View File

@@ -66,6 +66,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -105,6 +107,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -127,6 +130,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -138,6 +142,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -160,6 +165,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -200,12 +206,13 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
params: (params) => {
const { credential, folderSelector, manualFolderId, mimeType, ...rest } = params
// Use folderSelector if provided, otherwise use manualFolderId
const effectiveFolderId = (folderSelector || manualFolderId || '').trim()
return {
credential,
...rest,
accessToken: credential,
// Pass both; tools will prioritize manualFolderId over folderSelector
folderSelector,
manualFolderId,
folderId: effectiveFolderId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
}

View File

@@ -142,6 +142,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
title: 'Folder',
type: 'folder-selector',
layout: 'full',
canonicalParamId: 'folder',
provider: 'outlook',
serviceId: 'outlook',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
@@ -156,6 +157,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
title: 'Folder',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folder',
placeholder: 'Enter Outlook folder name (e.g., INBOX, SENT, or custom folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'read_outlook' },
@@ -196,13 +198,11 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
}
},
params: (params) => {
// Pass the credential directly from the credential field
const { credential, folder, manualFolder, ...rest } = params
// Handle folder input (selector or manual)
// Handle both selector and manual folder input
const effectiveFolder = (folder || manualFolder || '').trim()
// Set default folder to INBOX if not specified
if (rest.operation === 'read_outlook') {
rest.folder = effectiveFolder || 'INBOX'
}

View File

@@ -49,6 +49,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
title: 'Select Site',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'siteId',
provider: 'microsoft',
serviceId: 'sharepoint',
requiredScopes: [
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
title: 'Site ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'siteId',
placeholder: 'Enter site ID (leave empty for root site)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -127,8 +129,8 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
const effectiveSiteId = (siteSelector || manualSiteId || '').trim()
return {
accessToken: credential,
siteId: effectiveSiteId,
credential,
siteId: effectiveSiteId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
...rest,

View File

@@ -78,6 +78,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
title: 'Channel',
type: 'channel-selector',
layout: 'full',
canonicalParamId: 'channel',
provider: 'slack',
placeholder: 'Select Slack channel',
mode: 'basic',
@@ -89,6 +90,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
title: 'Channel ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'channel',
placeholder: 'Enter Slack channel ID (e.g., C1234567890)',
mode: 'advanced',
},
@@ -192,13 +194,11 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
...rest
} = params
// Handle channel input (selector or manual)
// Handle both selector and manual channel input
const effectiveChannel = (channel || manualChannel || '').trim()
if (!effectiveChannel) {
throw new Error(
'Channel is required. Please select a channel or enter a channel ID manually.'
)
throw new Error('Channel is required.')
}
const baseParams: Record<string, any> = {

View File

@@ -57,6 +57,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
layout: 'full',
placeholder: 'Enter Contact ID',
mode: 'basic',
canonicalParamId: 'contactId',
condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] },
},
{
@@ -64,6 +65,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
title: 'Contact ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'contactId',
placeholder: 'Enter Contact ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] },
@@ -75,6 +77,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
layout: 'full',
placeholder: 'Enter Task ID',
mode: 'basic',
canonicalParamId: 'taskId',
condition: { field: 'operation', value: ['read_task'] },
},
{
@@ -82,6 +85,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
title: 'Task ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'taskId',
placeholder: 'Enter Task ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_task'] },
@@ -180,19 +184,15 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
const { credential, operation, contactId, manualContactId, taskId, manualTaskId, ...rest } =
params
// Handle contact ID input (selector or manual)
// Handle both selector and manual inputs
const effectiveContactId = (contactId || manualContactId || '').trim()
// Handle task ID input (selector or manual)
const effectiveTaskId = (taskId || manualTaskId || '').trim()
// Build the parameters based on operation type
const baseParams = {
...rest,
credential,
}
// For note operations, we need noteId
if (operation === 'read_note' || operation === 'write_note') {
return {
...baseParams,
@@ -200,8 +200,6 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
contactId: effectiveContactId,
}
}
// For contact operations, we need contactId
if (operation === 'read_contact') {
if (!effectiveContactId) {
throw new Error('Contact ID is required for contact operations')
@@ -211,26 +209,22 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
contactId: effectiveContactId,
}
}
// For task operations, we need taskId
if (operation === 'read_task') {
if (!effectiveTaskId) {
if (!taskId?.trim()) {
throw new Error('Task ID is required for task operations')
}
return {
...baseParams,
taskId: effectiveTaskId,
taskId: taskId.trim(),
}
}
// For write_task and write_note operations, we need contactId
if (operation === 'write_task' || operation === 'write_note') {
if (!effectiveContactId) {
if (!contactId?.trim()) {
throw new Error('Contact ID is required for this operation')
}
return {
...baseParams,
contactId: effectiveContactId,
contactId: contactId.trim(),
}
}

View File

@@ -101,6 +101,7 @@ export interface SubBlockConfig {
type: SubBlockType
layout?: SubBlockLayout
mode?: 'basic' | 'advanced' | 'both' // Default is 'both' if not specified
canonicalParamId?: string
required?: boolean
options?:
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }[]

View File

@@ -13,7 +13,6 @@ import {
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -80,7 +79,7 @@ export const BatchInvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -14,7 +14,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -45,7 +44,7 @@ export const EnterpriseSubscriptionEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
@@ -94,7 +93,7 @@ export const EnterpriseSubscriptionEmail = ({
</Text>
<Text style={baseStyles.paragraph}>
Welcome to Sim Enterprise!
Best regards,
<br />
The Sim Team
</Text>

View File

@@ -13,7 +13,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -60,7 +59,7 @@ export const HelpConfirmationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -5,6 +5,8 @@ export { default as EmailFooter } from './footer'
export { HelpConfirmationEmail } from './help-confirmation-email'
export { InvitationEmail } from './invitation-email'
export { OTPVerificationEmail } from './otp-verification-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export * from './render-email'
export { ResetPasswordEmail } from './reset-password-email'
export { UsageThresholdEmail } from './usage-threshold-email'
export { WorkspaceInvitationEmail } from './workspace-invitation'

View File

@@ -15,7 +15,6 @@ import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -66,7 +65,7 @@ export const InvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -12,7 +12,6 @@ import {
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -72,7 +71,7 @@ export const OTPVerificationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -0,0 +1,113 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
createdDate?: Date
}
export function PlanWelcomeEmail({
planName,
userName,
loginLink,
createdDate = new Date(),
}: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const cta = loginLink || `${baseUrl}/login`
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
build, test, and scale your agentic workflows.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
Want to discuss your plan or get personalized help getting started?{' '}
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
Schedule a 15-minute call
</Link>{' '}
with our team.
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {createdDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default PlanWelcomeEmail

View File

@@ -5,7 +5,9 @@ import {
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
PlanWelcomeEmail,
ResetPasswordEmail,
UsageThresholdEmail,
} from '@/components/emails'
import { getBrandConfig } from '@/lib/branding/branding'
@@ -100,6 +102,27 @@ export async function renderEnterpriseSubscriptionEmail(
)
}
export async function renderUsageThresholdEmail(params: {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}): Promise<string> {
return await render(
UsageThresholdEmail({
userName: params.userName,
planName: params.planName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
updatedDate: new Date(),
})
)
}
export function getEmailSubject(
type:
| 'sign-in'
@@ -110,6 +133,9 @@ export function getEmailSubject(
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'plan-welcome-pro'
| 'plan-welcome-team'
): string {
const brandName = getBrandConfig().name
@@ -130,7 +156,28 @@ export function getEmailSubject(
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
default:
return brandName
}
}
export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
}): Promise<string> {
return await render(
PlanWelcomeEmail({
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
createdDate: new Date(),
})
)
}

View File

@@ -14,7 +14,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -43,7 +42,7 @@ export const ResetPasswordEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -0,0 +1,123 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
interface UsageThresholdEmailProps {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
updatedDate?: Date
}
export function UsageThresholdEmail({
userName,
planName,
percentUsed,
currentUsage,
limit,
ctaLink,
updatedDate = new Date(),
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
<strong>Usage</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Column>
</Row>
</Section>
<Hr />
<Text style={{ ...baseStyles.paragraph }}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review limits</Text>
</Link>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 80%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default UsageThresholdEmail

View File

@@ -14,7 +14,6 @@ import {
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -64,7 +63,7 @@ export const WorkspaceInvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -514,16 +514,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
`URL workflow changed from ${currentWorkflowId} to ${urlWorkflowId}, switching rooms`
)
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
// Flush debounced updates for the old workflow before switching rooms
if (currentWorkflowId) {
useOperationQueueStore.getState().flushDebouncedForWorkflow(currentWorkflowId)
} else {
useOperationQueueStore.getState().flushAllDebounced()
}
} catch {}
// Leave current workflow first if we're in one
if (currentWorkflowId) {
logger.info(`Leaving current workflow ${currentWorkflowId} before joining ${urlWorkflowId}`)
@@ -583,7 +573,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
logger.info(`Leaving workflow: ${currentWorkflowId}`)
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
useOperationQueueStore.getState().flushDebouncedForWorkflow(currentWorkflowId)
useOperationQueueStore.getState().cancelOperationsForWorkflow(currentWorkflowId)
} catch {}
socket.emit('leave-workflow')

View File

@@ -0,0 +1,2 @@
ALTER TABLE "settings" ADD COLUMN "billing_usage_notifications_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" DROP COLUMN "general";

File diff suppressed because it is too large Load Diff

View File

@@ -589,6 +589,13 @@
"when": 1757046301281,
"tag": "0084_even_lockheed",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1757348840739,
"tag": "0085_daffy_blacklash",
"breakpoints": true
}
]
}

View File

@@ -374,8 +374,10 @@ export const settings = pgTable('settings', {
// Email preferences
emailPreferences: json('email_preferences').notNull().default('{}'),
// Keep general for future flexible settings
general: json('general').notNull().default('{}'),
// Billing usage notifications preference
billingUsageNotificationsEnabled: boolean('billing_usage_notifications_enabled')
.notNull()
.default(true),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})

View File

@@ -1229,6 +1229,17 @@ export const auth = betterAuth({
error,
})
}
// Send welcome email for Pro and Team plans
try {
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
await sendPlanWelcomeEmail(subscription)
} catch (error) {
logger.error('[onSubscriptionComplete] Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
})
}
},
onSubscriptionUpdate: async ({
subscription,

View File

@@ -35,7 +35,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
: 0
return {
percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100),
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
isWarning: false,
isExceeded: false,
currentUsage,
@@ -69,7 +69,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
)
// Calculate percentage used
const percentUsed = Math.min(Math.floor((currentUsage / limit) * 100), 100)
const percentUsed = Math.min((currentUsage / limit) * 100, 100)
// Check org-level cap for team/enterprise pooled usage
let isExceeded = currentUsage >= limit

View File

@@ -267,7 +267,7 @@ export async function getSimplifiedBillingSummary(
}
const overageAmount = Math.max(0, currentUsage - basePrice)
const percentUsed = usageData.limit > 0 ? Math.round((currentUsage / usageData.limit) * 100) : 0
const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0
// Calculate days remaining in billing period
const daysRemaining = usageData.billingPeriodEnd

View File

@@ -7,10 +7,11 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import { env } from '@/lib/env'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { member, subscription, user, userStats } from '@/db/schema'
const logger = createLogger('SubscriptionCore')
@@ -74,7 +75,6 @@ export async function getHighestPrioritySubscription(userId: string) {
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
// In development, enable Pro features for easier testing
if (!isProd) {
return true
}
@@ -155,7 +155,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
const subscription = await getHighestPrioritySubscription(userId)
// Calculate usage limit
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
@@ -283,3 +282,54 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
}
}
}
/**
* Send welcome email for Pro and Team plan subscriptions
*/
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
try {
const subPlan = subscription.plan
if (subPlan === 'pro' || subPlan === 'team') {
const userId = subscription.referenceId
const users = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (users.length > 0 && users[0].email) {
const { getEmailSubject, renderPlanWelcomeEmail } = await import(
'@/components/emails/render-email'
)
const { sendEmail } = await import('@/lib/email/mailer')
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const html = await renderPlanWelcomeEmail({
planName: subPlan === 'pro' ? 'Pro' : 'Team',
userName: users[0].name || undefined,
loginLink: `${baseUrl}/login`,
})
await sendEmail({
to: users[0].email,
subject: getEmailSubject(subPlan === 'pro' ? 'plan-welcome-pro' : 'plan-welcome-team'),
html,
emailType: 'updates',
})
logger.info('Plan welcome email sent successfully', {
userId,
email: users[0].email,
plan: subPlan,
})
}
}
} catch (error) {
logger.error('Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
plan: subscription.plan,
})
throw error
}
}

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
canEditUsageLimit,
@@ -6,9 +7,12 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { sendEmail } from '@/lib/email/mailer'
import { getEmailPreferences } from '@/lib/email/unsubscribe'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, organization, user, userStats } from '@/db/schema'
import { member, organization, settings, user, userStats } from '@/db/schema'
const logger = createLogger('UsageManagement')
@@ -82,7 +86,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
}
}
const percentUsed = limit > 0 ? Math.min(Math.floor((currentUsage / limit) * 100), 100) : 0
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
const isWarning = percentUsed >= 80
const isExceeded = currentUsage >= limit
@@ -531,3 +535,89 @@ export async function calculateBillingProjection(userId: string): Promise<Billin
throw error
}
}
/**
* Send usage threshold notification when crossing from <80% to ≥80%.
* - Skips when billing is disabled.
* - Respects user-level notifications toggle and unsubscribe preferences.
* - For organization plans, emails owners/admins who have notifications enabled.
*/
export async function maybeSendUsageThresholdEmail(params: {
scope: 'user' | 'organization'
planName: string
percentBefore: number
percentAfter: number
userId?: string
userEmail?: string
userName?: string
organizationId?: string
currentUsageAfter: number
limit: number
}): Promise<void> {
try {
if (!isBillingEnabled) return
// Only on upward crossing to >= 80%
if (!(params.percentBefore < 80 && params.percentAfter >= 80)) return
if (params.limit <= 0 || params.currentUsageAfter <= 0) return
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const ctaLink = `${baseUrl}/workspace?billing=usage`
const sendTo = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderUsageThresholdEmail({
userName: name,
planName: params.planName,
percentUsed: Math.min(100, Math.round(params.percentAfter)),
currentUsage: params.currentUsageAfter,
limit: params.limit,
ctaLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('usage-threshold'),
html,
emailType: 'notifications',
})
}
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendTo(params.userEmail, params.userName)
} else if (params.scope === 'organization' && params.organizationId) {
const admins = await db
.select({
email: user.email,
name: user.name,
enabled: settings.billingUsageNotificationsEnabled,
role: member.role,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(settings, eq(settings.userId, member.userId))
.where(eq(member.organizationId, params.organizationId))
for (const a of admins) {
const isAdmin = a.role === 'owner' || a.role === 'admin'
if (!isAdmin) continue
if (a.enabled === false) continue
if (!a.email) continue
await sendTo(a.email, a.name || undefined)
}
}
} catch (error) {
logger.error('Failed to send usage threshold email', {
scope: params.scope,
userId: params.userId,
organizationId: params.organizationId,
error,
})
}
}

View File

@@ -13,6 +13,7 @@ export {
isEnterprisePlan as hasEnterprisePlan,
isProPlan as hasProPlan,
isTeamPlan as hasTeamPlan,
sendPlanWelcomeEmail,
} from '@/lib/billing/core/subscription'
export * from '@/lib/billing/core/usage'
export {

View File

@@ -1,5 +1,7 @@
import { eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
import { getCostMultiplier, isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
@@ -14,7 +16,14 @@ import type {
WorkflowState,
} from '@/lib/logs/types'
import { db } from '@/db'
import { userStats, workflow, workflowExecutionLogs } from '@/db/schema'
import {
member,
organization,
userStats,
user as userTable,
workflow,
workflowExecutionLogs,
} from '@/db/schema'
export interface ToolCall {
name: string
@@ -173,12 +182,127 @@ export class ExecutionLogger implements IExecutionLoggerService {
throw new Error(`Workflow log not found for execution ${executionId}`)
}
// Update user stats with cost information (same logic as original execution logger)
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
try {
const [wf] = await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId))
if (wf) {
const [usr] = await db
.select({ id: userTable.id, email: userTable.email, name: userTable.name })
.from(userTable)
.where(eq(userTable.id, wf.userId))
.limit(1)
if (usr?.email) {
const sub = await getHighestPrioritySubscription(usr.id)
const costMultiplier = getCostMultiplier()
const costDelta =
(costSummary.baseExecutionCharge || 0) + (costSummary.modelCost || 0) * costMultiplier
const planName = sub?.plan || 'Free'
const scope: 'user' | 'organization' =
sub && (sub.plan === 'team' || sub.plan === 'enterprise') ? 'organization' : 'user'
if (scope === 'user') {
const before = await checkUsageStatus(usr.id)
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
const limit = before.usageData.limit
const percentBefore = before.usageData.percentUsed
const percentAfter =
limit > 0 ? Math.min(100, percentBefore + (costDelta / limit) * 100) : percentBefore
const currentUsageAfter = before.usageData.currentUsage + costDelta
await maybeSendUsageThresholdEmail({
scope: 'user',
userId: usr.id,
userEmail: usr.email,
userName: usr.name || undefined,
planName,
percentBefore,
percentAfter,
currentUsageAfter,
limit,
})
} else if (sub?.referenceId) {
let orgLimit = 0
const orgRows = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan)
const minimum = (sub.seats || 1) * basePrice
if (orgRows.length > 0 && orgRows[0].orgUsageLimit) {
const configured = Number.parseFloat(orgRows[0].orgUsageLimit)
orgLimit = Math.max(configured, minimum)
} else {
orgLimit = minimum
}
const [{ sum: orgUsageBefore }] = await db
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, sub.referenceId))
.limit(1)
const orgUsageBeforeNum = Number.parseFloat(
(orgUsageBefore as any)?.toString?.() || '0'
)
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
const percentBefore =
orgLimit > 0 ? Math.min(100, (orgUsageBeforeNum / orgLimit) * 100) : 0
const percentAfter =
orgLimit > 0
? Math.min(100, percentBefore + (costDelta / orgLimit) * 100)
: percentBefore
const currentUsageAfter = orgUsageBeforeNum + costDelta
await maybeSendUsageThresholdEmail({
scope: 'organization',
organizationId: sub.referenceId,
planName,
percentBefore,
percentAfter,
currentUsageAfter,
limit: orgLimit,
})
}
} else {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
}
} else {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
}
} catch (e) {
try {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
} catch {}
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
}
logger.debug(`Completed workflow execution ${executionId}`)

View File

@@ -248,15 +248,54 @@ export class Serializer {
blockConfig.subBlocks.forEach((subBlockConfig) => {
const id = subBlockConfig.id
if (
params[id] === null &&
(params[id] === null || params[id] === undefined) &&
subBlockConfig.value &&
shouldIncludeField(subBlockConfig, isAdvancedMode)
) {
// If the value is null and there's a default value function, use it
// If the value is absent and there's a default value function, use it
params[id] = subBlockConfig.value(params)
}
})
// Finally, consolidate canonical parameters (e.g., selector and manual ID into a single param)
const canonicalGroups: Record<string, { basic?: string; advanced: string[] }> = {}
blockConfig.subBlocks.forEach((sb) => {
if (!sb.canonicalParamId) return
const key = sb.canonicalParamId
if (!canonicalGroups[key]) canonicalGroups[key] = { basic: undefined, advanced: [] }
if (sb.mode === 'advanced') canonicalGroups[key].advanced.push(sb.id)
else canonicalGroups[key].basic = sb.id
})
Object.entries(canonicalGroups).forEach(([canonicalKey, group]) => {
const basicId = group.basic
const advancedIds = group.advanced
const basicVal = basicId ? params[basicId] : undefined
const advancedVal = advancedIds
.map((id) => params[id])
.find(
(v) => v !== undefined && v !== null && (typeof v !== 'string' || v.trim().length > 0)
)
let chosen: any
if (advancedVal !== undefined && basicVal !== undefined) {
chosen = isAdvancedMode ? advancedVal : basicVal
} else if (advancedVal !== undefined) {
chosen = advancedVal
} else if (basicVal !== undefined) {
chosen = isAdvancedMode ? undefined : basicVal
} else {
chosen = undefined
}
const sourceIds = [basicId, ...advancedIds].filter(Boolean) as string[]
sourceIds.forEach((id) => {
if (id !== canonicalKey) delete params[id]
})
if (chosen !== undefined) params[canonicalKey] = chosen
else delete params[canonicalKey]
})
return params
}

View File

@@ -8,6 +8,16 @@ import type { RoomManager } from '@/socket-server/rooms/manager'
const logger = createLogger('SubblocksHandlers')
type PendingSubblock = {
latest: { blockId: string; subblockId: string; value: any; timestamp: number }
timeout: NodeJS.Timeout
// Map operationId -> socketId to emit confirmations/failures to correct clients
opToSocket: Map<string, string>
}
// Keyed by `${workflowId}:${blockId}:${subblockId}`
const pendingSubblockUpdates = new Map<string, PendingSubblock>()
export function setupSubblocksHandlers(
socket: AuthenticatedSocket,
deps: HandlerDependencies | RoomManager
@@ -46,93 +56,31 @@ export function setupSubblocksHandlers(
userPresence.lastActivity = Date.now()
}
// First, verify that the workflow still exists in the database
const workflowExists = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
logger.warn(`Ignoring subblock update: workflow ${workflowId} no longer exists`, {
socketId: socket.id,
blockId,
subblockId,
})
roomManager.cleanupUserFromRoom(socket.id, workflowId)
return
}
let updateSuccessful = false
await db.transaction(async (tx) => {
const [block] = await tx
.select({ subBlocks: workflowBlocks.subBlocks })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!block) {
// Block was deleted - this is a normal race condition in collaborative editing
logger.debug(
`Ignoring subblock update for deleted block: ${workflowId}/${blockId}.${subblockId}`
)
return
}
const subBlocks = (block.subBlocks as any) || {}
if (!subBlocks[subblockId]) {
// Create new subblock with minimal structure
subBlocks[subblockId] = {
id: subblockId,
type: 'unknown', // Will be corrected by next collaborative update
value: value,
// Server-side debounce/coalesce by workflowId+blockId+subblockId
const debouncedKey = `${workflowId}:${blockId}:${subblockId}`
const existing = pendingSubblockUpdates.get(debouncedKey)
if (existing) {
clearTimeout(existing.timeout)
existing.latest = { blockId, subblockId, value, timestamp }
if (operationId) existing.opToSocket.set(operationId, socket.id)
existing.timeout = setTimeout(async () => {
await flushSubblockUpdate(workflowId, existing, roomManager)
pendingSubblockUpdates.delete(debouncedKey)
}, 25)
} else {
const opToSocket = new Map<string, string>()
if (operationId) opToSocket.set(operationId, socket.id)
const timeout = setTimeout(async () => {
const pending = pendingSubblockUpdates.get(debouncedKey)
if (pending) {
await flushSubblockUpdate(workflowId, pending, roomManager)
pendingSubblockUpdates.delete(debouncedKey)
}
} else {
// Preserve existing id and type, only update value
subBlocks[subblockId] = {
...subBlocks[subblockId],
value: value,
}
}
await tx
.update(workflowBlocks)
.set({
subBlocks: subBlocks,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
updateSuccessful = true
})
// Only broadcast to other clients if the update was successful
if (updateSuccessful) {
socket.to(workflowId).emit('subblock-update', {
blockId,
subblockId,
value,
timestamp,
senderId: socket.id,
userId: session.userId,
})
// Emit confirmation if operationId is provided
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
logger.debug(`Subblock update in workflow ${workflowId}: ${blockId}.${subblockId}`)
} else if (operationId) {
// Block was deleted - notify client that operation completed (but didn't update anything)
socket.emit('operation-failed', {
operationId,
error: 'Block no longer exists',
retryable: false, // No point retrying for deleted blocks
}, 25)
pendingSubblockUpdates.set(debouncedKey, {
latest: { blockId, subblockId, value, timestamp },
timeout,
opToSocket,
})
}
} catch (error) {
@@ -140,12 +88,12 @@ export function setupSubblocksHandlers(
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Emit operation-failed for queue-tracked operations
// Best-effort failure for the single operation if provided
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: errorMessage,
retryable: true, // Subblock updates are generally retryable
retryable: true,
})
}
@@ -159,3 +107,119 @@ export function setupSubblocksHandlers(
}
})
}
async function flushSubblockUpdate(
workflowId: string,
pending: PendingSubblock,
roomManager: RoomManager
) {
const { blockId, subblockId, value, timestamp } = pending.latest
try {
// Verify workflow still exists
const workflowExists = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: false,
})
}
})
return
}
let updateSuccessful = false
await db.transaction(async (tx) => {
const [block] = await tx
.select({ subBlocks: workflowBlocks.subBlocks })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!block) {
return
}
const subBlocks = (block.subBlocks as any) || {}
if (!subBlocks[subblockId]) {
subBlocks[subblockId] = { id: subblockId, type: 'unknown', value }
} else {
subBlocks[subblockId] = { ...subBlocks[subblockId], value }
}
await tx
.update(workflowBlocks)
.set({ subBlocks, updatedAt: new Date() })
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
updateSuccessful = true
})
if (updateSuccessful) {
// Broadcast to other clients (exclude senders to avoid overwriting their local state)
const senderSocketIds = new Set(pending.opToSocket.values())
const io = (roomManager as any).io
if (io) {
// Get all sockets in the room
const roomSockets = io.sockets.adapter.rooms.get(workflowId)
if (roomSockets) {
roomSockets.forEach((socketId: string) => {
// Only emit to sockets that didn't send any of the coalesced ops
if (!senderSocketIds.has(socketId)) {
const sock = io.sockets.sockets.get(socketId)
if (sock) {
sock.emit('subblock-update', {
blockId,
subblockId,
value,
timestamp,
})
}
}
})
}
}
// Confirm all coalesced operationIds
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-confirmed', { operationId: opId, serverTimestamp: Date.now() })
}
})
logger.debug(`Flushed subblock update ${workflowId}: ${blockId}.${subblockId}`)
} else {
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: 'Block no longer exists',
retryable: false,
})
}
})
}
} catch (error) {
logger.error('Error flushing subblock update:', error)
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: error instanceof Error ? error.message : 'Unknown error',
retryable: true,
})
}
})
}
}

View File

@@ -8,6 +8,15 @@ import type { RoomManager } from '@/socket-server/rooms/manager'
const logger = createLogger('VariablesHandlers')
type PendingVariable = {
latest: { variableId: string; field: string; value: any; timestamp: number }
timeout: NodeJS.Timeout
opToSocket: Map<string, string>
}
// Keyed by `${workflowId}:${variableId}:${field}`
const pendingVariableUpdates = new Map<string, PendingVariable>()
export function setupVariablesHandlers(
socket: AuthenticatedSocket,
deps: HandlerDependencies | RoomManager
@@ -47,85 +56,30 @@ export function setupVariablesHandlers(
userPresence.lastActivity = Date.now()
}
const workflowExists = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
logger.warn(`Ignoring variable update: workflow ${workflowId} no longer exists`, {
socketId: socket.id,
variableId,
field,
})
roomManager.cleanupUserFromRoom(socket.id, workflowId)
return
}
let updateSuccessful = false
await db.transaction(async (tx) => {
const [workflowRecord] = await tx
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
logger.debug(
`Ignoring variable update for deleted workflow: ${workflowId}/${variableId}.${field}`
)
return
}
const variables = (workflowRecord.variables as any) || {}
if (!variables[variableId]) {
logger.debug(
`Ignoring variable update for deleted variable: ${workflowId}/${variableId}.${field}`
)
return
}
variables[variableId] = {
...variables[variableId],
[field]: value,
}
await tx
.update(workflow)
.set({
variables: variables,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
updateSuccessful = true
})
if (updateSuccessful) {
socket.to(workflowId).emit('variable-update', {
variableId,
field,
value,
timestamp,
senderId: socket.id,
userId: session.userId,
})
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
logger.debug(`Variable update in workflow ${workflowId}: ${variableId}.${field}`)
} else if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'Variable no longer exists',
retryable: false,
const debouncedKey = `${workflowId}:${variableId}:${field}`
const existing = pendingVariableUpdates.get(debouncedKey)
if (existing) {
clearTimeout(existing.timeout)
existing.latest = { variableId, field, value, timestamp }
if (operationId) existing.opToSocket.set(operationId, socket.id)
existing.timeout = setTimeout(async () => {
await flushVariableUpdate(workflowId, existing, roomManager)
pendingVariableUpdates.delete(debouncedKey)
}, 25)
} else {
const opToSocket = new Map<string, string>()
if (operationId) opToSocket.set(operationId, socket.id)
const timeout = setTimeout(async () => {
const pending = pendingVariableUpdates.get(debouncedKey)
if (pending) {
await flushVariableUpdate(workflowId, pending, roomManager)
pendingVariableUpdates.delete(debouncedKey)
}
}, 25)
pendingVariableUpdates.set(debouncedKey, {
latest: { variableId, field, value, timestamp },
timeout,
opToSocket,
})
}
} catch (error) {
@@ -150,3 +104,118 @@ export function setupVariablesHandlers(
}
})
}
async function flushVariableUpdate(
workflowId: string,
pending: PendingVariable,
roomManager: RoomManager
) {
const { variableId, field, value, timestamp } = pending.latest
try {
const workflowExists = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: false,
})
}
})
return
}
let updateSuccessful = false
await db.transaction(async (tx) => {
const [workflowRecord] = await tx
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return
}
const variables = (workflowRecord.variables as any) || {}
if (!variables[variableId]) {
return
}
variables[variableId] = {
...variables[variableId],
[field]: value,
}
await tx
.update(workflow)
.set({ variables, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
updateSuccessful = true
})
if (updateSuccessful) {
// Broadcast to other clients (exclude senders to avoid overwriting their local state)
const senderSocketIds = new Set(pending.opToSocket.values())
const io = (roomManager as any).io
if (io) {
const roomSockets = io.sockets.adapter.rooms.get(workflowId)
if (roomSockets) {
roomSockets.forEach((socketId: string) => {
if (!senderSocketIds.has(socketId)) {
const sock = io.sockets.sockets.get(socketId)
if (sock) {
sock.emit('variable-update', {
variableId,
field,
value,
timestamp,
})
}
}
})
}
}
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-confirmed', { operationId: opId, serverTimestamp: Date.now() })
}
})
logger.debug(`Flushed variable update ${workflowId}: ${variableId}.${field}`)
} else {
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: 'Variable no longer exists',
retryable: false,
})
}
})
}
} catch (error) {
logger.error('Error flushing variable update:', error)
pending.opToSocket.forEach((socketId, opId) => {
const sock = (roomManager as any).io?.sockets?.sockets?.get(socketId)
if (sock) {
sock.emit('operation-failed', {
operationId: opId,
error: error instanceof Error ? error.message : 'Unknown error',
retryable: true,
})
}
})
}
}

View File

@@ -260,6 +260,10 @@ export class RoomManager {
}
}
emitToWorkflow<T = unknown>(workflowId: string, event: string, payload: T): void {
this.io.to(workflowId).emit(event, payload)
}
/**
* Get the number of unique users in a workflow room
* (not the number of socket connections)

View File

@@ -93,11 +93,6 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void {
}
}
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
useOperationQueueStore.getState().flushAllDebounced()
} catch {}
// Standard beforeunload pattern
event.preventDefault()
event.returnValue = ''

View File

@@ -31,10 +31,6 @@ interface OperationQueueState {
cancelOperationsForBlock: (blockId: string) => void
cancelOperationsForVariable: (variableId: string) => void
flushAllDebounced: () => void
flushDebouncedForBlock: (blockId: string) => void
flushDebouncedForVariable: (variableId: string) => void
flushDebouncedForWorkflow: (workflowId: string) => void
cancelOperationsForWorkflow: (workflowId: string) => void
triggerOfflineMode: () => void
@@ -44,14 +40,6 @@ interface OperationQueueState {
const retryTimeouts = new Map<string, NodeJS.Timeout>()
const operationTimeouts = new Map<string, NodeJS.Timeout>()
type PendingDebouncedOperation = {
timeout: NodeJS.Timeout
op: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>
}
const subblockDebounced = new Map<string, PendingDebouncedOperation>()
const variableDebounced = new Map<string, PendingDebouncedOperation>()
let emitWorkflowOperation:
| ((operation: string, target: string, payload: any, operationId?: string) => void)
| null = null
@@ -82,107 +70,52 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
hasOperationError: false,
addToQueue: (operation) => {
// Handle debouncing for regular subblock operations (but not immediate ones like tag selections)
// Immediate coalescing without client-side debouncing:
// For subblock updates, keep only latest pending op for the same blockId+subblockId
if (
operation.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock' &&
!operation.immediate
operation.operation.target === 'subblock'
) {
const { blockId, subblockId } = operation.operation.payload
const debounceKey = `${blockId}-${subblockId}`
const existing = subblockDebounced.get(debounceKey)
if (existing) {
clearTimeout(existing.timeout)
}
set((state) => ({
operations: state.operations.filter(
(op) =>
!(
op.status === 'pending' &&
op.operation.operation === 'subblock-update' &&
op.operation.target === 'subblock' &&
op.operation.payload?.blockId === blockId &&
op.operation.payload?.subblockId === subblockId
)
),
operations: [
...state.operations.filter(
(op) =>
!(
op.status === 'pending' &&
op.operation.operation === 'subblock-update' &&
op.operation.target === 'subblock' &&
op.operation.payload?.blockId === blockId &&
op.operation.payload?.subblockId === subblockId
)
),
],
}))
const timeoutId = setTimeout(() => {
const pending = subblockDebounced.get(debounceKey)
subblockDebounced.delete(debounceKey)
if (pending) {
const queuedOp: QueuedOperation = {
...pending.op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
}
set((state) => ({
operations: [...state.operations, queuedOp],
}))
get().processNextOperation()
}
}, 25)
subblockDebounced.set(debounceKey, { timeout: timeoutId, op: operation })
return
}
// Handle debouncing for variable operations
// For variable updates, keep only latest pending op for same variableId+field
if (
operation.operation.operation === 'variable-update' &&
operation.operation.target === 'variable' &&
!operation.immediate
operation.operation.target === 'variable'
) {
const { variableId, field } = operation.operation.payload
const debounceKey = `${variableId}-${field}`
const existing = variableDebounced.get(debounceKey)
if (existing) {
clearTimeout(existing.timeout)
}
set((state) => ({
operations: state.operations.filter(
(op) =>
!(
op.status === 'pending' &&
op.operation.operation === 'variable-update' &&
op.operation.target === 'variable' &&
op.operation.payload?.variableId === variableId &&
op.operation.payload?.field === field
)
),
operations: [
...state.operations.filter(
(op) =>
!(
op.status === 'pending' &&
op.operation.operation === 'variable-update' &&
op.operation.target === 'variable' &&
op.operation.payload?.variableId === variableId &&
op.operation.payload?.field === field
)
),
],
}))
const timeoutId = setTimeout(() => {
const pending = variableDebounced.get(debounceKey)
variableDebounced.delete(debounceKey)
if (pending) {
const queuedOp: QueuedOperation = {
...pending.op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
}
set((state) => ({
operations: [...state.operations, queuedOp],
}))
get().processNextOperation()
}
}, 25)
variableDebounced.set(debounceKey, { timeout: timeoutId, op: operation })
return
}
// Handle non-subblock operations (existing logic)
// Handle remaining logic
const state = get()
// Check for duplicate operation ID
@@ -261,34 +194,6 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
operationTimeouts.delete(operationId)
}
// Clean up any debounce timeouts for subblock operations
if (
operation?.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock'
) {
const { blockId, subblockId } = operation.operation.payload
const debounceKey = `${blockId}-${subblockId}`
const pending = subblockDebounced.get(debounceKey)
if (pending) {
clearTimeout(pending.timeout)
subblockDebounced.delete(debounceKey)
}
}
// Clean up any debounce timeouts for variable operations
if (
operation?.operation.operation === 'variable-update' &&
operation.operation.target === 'variable'
) {
const { variableId, field } = operation.operation.payload
const debounceKey = `${variableId}-${field}`
const pending = variableDebounced.get(debounceKey)
if (pending) {
clearTimeout(pending.timeout)
variableDebounced.delete(debounceKey)
}
}
logger.debug('Removing operation from queue', {
operationId,
remainingOps: newOperations.length,
@@ -314,34 +219,6 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
operationTimeouts.delete(operationId)
}
// Clean up any debounce timeouts for subblock operations
if (
operation.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock'
) {
const { blockId, subblockId } = operation.operation.payload
const debounceKey = `${blockId}-${subblockId}`
const pending = subblockDebounced.get(debounceKey)
if (pending) {
clearTimeout(pending.timeout)
subblockDebounced.delete(debounceKey)
}
}
// Clean up any debounce timeouts for variable operations
if (
operation.operation.operation === 'variable-update' &&
operation.operation.target === 'variable'
) {
const { variableId, field } = operation.operation.payload
const debounceKey = `${variableId}-${field}`
const pending = variableDebounced.get(debounceKey)
if (pending) {
clearTimeout(pending.timeout)
variableDebounced.delete(debounceKey)
}
}
if (!retryable) {
logger.debug('Operation marked as non-retryable, removing from queue', { operationId })
@@ -354,14 +231,30 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
return
}
if (operation.retryCount < 3) {
const newRetryCount = operation.retryCount + 1
const delay = 2 ** newRetryCount * 1000 // 2s, 4s, 8s
// More aggressive retry for subblock/variable updates, less aggressive for structural ops
const isSubblockOrVariable =
(operation.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock') ||
(operation.operation.operation === 'variable-update' &&
operation.operation.target === 'variable')
logger.warn(`Operation failed, retrying in ${delay}ms (attempt ${newRetryCount}/3)`, {
operationId,
retryCount: newRetryCount,
})
const maxRetries = isSubblockOrVariable ? 5 : 3 // 5 retries for text, 3 for structural
if (operation.retryCount < maxRetries) {
const newRetryCount = operation.retryCount + 1
// Faster retries for subblock/variable, exponential for structural
const delay = isSubblockOrVariable
? Math.min(1000 * newRetryCount, 3000) // 1s, 2s, 3s, 3s, 3s (cap at 3s)
: 2 ** newRetryCount * 1000 // 2s, 4s, 8s (exponential for structural)
logger.warn(
`Operation failed, retrying in ${delay}ms (attempt ${newRetryCount}/${maxRetries})`,
{
operationId,
retryCount: newRetryCount,
operation: operation.operation.operation,
}
)
// Update retry count and mark as pending for retry
set((state) => ({
@@ -381,7 +274,12 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
retryTimeouts.set(operationId, timeout)
} else {
logger.error('Operation failed after max retries, triggering offline mode', { operationId })
// Always trigger offline mode when we can't persist - never silently drop data
logger.error('Operation failed after max retries, triggering offline mode', {
operationId,
operation: operation.operation.operation,
retryCount: operation.retryCount,
})
get().triggerOfflineMode()
}
},
@@ -452,14 +350,22 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
}
}
// Create operation timeout
// Create operation timeout - longer for subblock/variable updates to handle reconnects
const isSubblockOrVariable =
(nextOperation.operation.operation === 'subblock-update' &&
nextOperation.operation.target === 'subblock') ||
(nextOperation.operation.operation === 'variable-update' &&
nextOperation.operation.target === 'variable')
const timeoutDuration = isSubblockOrVariable ? 15000 : 5000 // 15s for text edits, 5s for structural ops
const timeoutId = setTimeout(() => {
logger.warn('Operation timeout - no server response after 5 seconds', {
logger.warn(`Operation timeout - no server response after ${timeoutDuration}ms`, {
operationId: nextOperation.id,
operation: nextOperation.operation.operation,
})
operationTimeouts.delete(nextOperation.id)
get().handleOperationTimeout(nextOperation.id)
}, 5000)
}, timeoutDuration)
operationTimeouts.set(nextOperation.id, timeoutId)
},
@@ -467,15 +373,7 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
cancelOperationsForBlock: (blockId: string) => {
logger.debug('Canceling all operations for block', { blockId })
// Cancel all debounce timeouts for this block's subblocks
const keysToDelete: string[] = []
for (const [key, pending] of subblockDebounced.entries()) {
if (key.startsWith(`${blockId}-`)) {
clearTimeout(pending.timeout)
keysToDelete.push(key)
}
}
keysToDelete.forEach((key) => subblockDebounced.delete(key))
// No debounced timeouts to cancel (moved to server-side)
// Find and cancel operation timeouts for operations related to this block
const state = get()
@@ -516,7 +414,6 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
logger.debug('Cancelled operations for block', {
blockId,
cancelledDebounceTimeouts: keysToDelete.length,
cancelledOperations: operationsToCancel.length,
})
@@ -527,15 +424,7 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
cancelOperationsForVariable: (variableId: string) => {
logger.debug('Canceling all operations for variable', { variableId })
// Cancel all debounce timeouts for this variable
const keysToDelete: string[] = []
for (const [key, pending] of variableDebounced.entries()) {
if (key.startsWith(`${variableId}-`)) {
clearTimeout(pending.timeout)
keysToDelete.push(key)
}
}
keysToDelete.forEach((key) => variableDebounced.delete(key))
// No debounced timeouts to cancel (moved to server-side)
// Find and cancel operation timeouts for operations related to this variable
const state = get()
@@ -578,7 +467,6 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
logger.debug('Cancelled operations for variable', {
variableId,
cancelledDebounceTimeouts: keysToDelete.length,
cancelledOperations: operationsToCancel.length,
})
@@ -586,120 +474,6 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
get().processNextOperation()
},
flushAllDebounced: () => {
const toEnqueue: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>[] = []
subblockDebounced.forEach((pending, key) => {
clearTimeout(pending.timeout)
subblockDebounced.delete(key)
toEnqueue.push(pending.op)
})
variableDebounced.forEach((pending, key) => {
clearTimeout(pending.timeout)
variableDebounced.delete(key)
toEnqueue.push(pending.op)
})
if (toEnqueue.length === 0) return
set((state) => ({
operations: [
...state.operations,
...toEnqueue.map((op) => ({
...op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending' as const,
})),
],
}))
get().processNextOperation()
},
flushDebouncedForBlock: (blockId: string) => {
const toEnqueue: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>[] = []
const keys: string[] = []
subblockDebounced.forEach((pending, key) => {
if (key.startsWith(`${blockId}-`)) {
clearTimeout(pending.timeout)
keys.push(key)
toEnqueue.push(pending.op)
}
})
keys.forEach((k) => subblockDebounced.delete(k))
if (toEnqueue.length === 0) return
set((state) => ({
operations: [
...state.operations,
...toEnqueue.map((op) => ({
...op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending' as const,
})),
],
}))
get().processNextOperation()
},
flushDebouncedForVariable: (variableId: string) => {
const toEnqueue: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>[] = []
const keys: string[] = []
variableDebounced.forEach((pending, key) => {
if (key.startsWith(`${variableId}-`)) {
clearTimeout(pending.timeout)
keys.push(key)
toEnqueue.push(pending.op)
}
})
keys.forEach((k) => variableDebounced.delete(k))
if (toEnqueue.length === 0) return
set((state) => ({
operations: [
...state.operations,
...toEnqueue.map((op) => ({
...op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending' as const,
})),
],
}))
get().processNextOperation()
},
flushDebouncedForWorkflow: (workflowId: string) => {
const toEnqueue: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>[] = []
const subblockKeys: string[] = []
subblockDebounced.forEach((pending, key) => {
if (pending.op.workflowId === workflowId) {
clearTimeout(pending.timeout)
subblockKeys.push(key)
toEnqueue.push(pending.op)
}
})
subblockKeys.forEach((k) => subblockDebounced.delete(k))
const variableKeys: string[] = []
variableDebounced.forEach((pending, key) => {
if (pending.op.workflowId === workflowId) {
clearTimeout(pending.timeout)
variableKeys.push(key)
toEnqueue.push(pending.op)
}
})
variableKeys.forEach((k) => variableDebounced.delete(k))
if (toEnqueue.length === 0) return
set((state) => ({
operations: [
...state.operations,
...toEnqueue.map((op) => ({
...op,
timestamp: Date.now(),
retryCount: 0,
status: 'pending' as const,
})),
],
}))
get().processNextOperation()
},
cancelOperationsForWorkflow: (workflowId: string) => {
const state = get()
retryTimeouts.forEach((timeout, opId) => {

View File

@@ -32,6 +32,8 @@ export const useGeneralStore = create<GeneralStore>()(
isConsoleExpandedByDefaultLoading: false,
isThemeLoading: false, // Keep for compatibility but not used
isTelemetryLoading: false,
isBillingUsageNotificationsLoading: false,
isBillingUsageNotificationsEnabled: true,
}
// Optimistic update helper
@@ -133,6 +135,16 @@ export const useGeneralStore = create<GeneralStore>()(
)
},
setBillingUsageNotificationsEnabled: async (enabled: boolean) => {
if (get().isBillingUsageNotificationsLoading) return
await updateSettingOptimistic(
'isBillingUsageNotificationsEnabled',
enabled,
'isBillingUsageNotificationsLoading',
'isBillingUsageNotificationsEnabled'
)
},
// API Actions
loadSettings: async (force = false) => {
// Skip if we've already loaded from DB and not forcing
@@ -193,6 +205,7 @@ export const useGeneralStore = create<GeneralStore>()(
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
theme: data.theme || 'system',
telemetryEnabled: data.telemetryEnabled,
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
isLoading: false,
})

View File

@@ -7,12 +7,13 @@ export interface General {
telemetryEnabled: boolean
isLoading: boolean
error: string | null
// Individual loading states for optimistic updates
isAutoConnectLoading: boolean
isAutoPanLoading: boolean
isConsoleExpandedByDefaultLoading: boolean
isThemeLoading: boolean
isTelemetryLoading: boolean
isBillingUsageNotificationsLoading: boolean
isBillingUsageNotificationsEnabled: boolean
}
export interface GeneralActions {
@@ -23,6 +24,7 @@ export interface GeneralActions {
toggleDebugMode: () => void
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
setTelemetryEnabled: (enabled: boolean) => Promise<void>
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
loadSettings: (force?: boolean) => Promise<void>
updateSetting: <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => Promise<void>
}
@@ -35,4 +37,5 @@ export type UserSettings = {
autoPan: boolean
consoleExpandedByDefault: boolean
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
}

View File

@@ -435,7 +435,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, 'Remove connection')
get().updateLastSaved()
// get().sync.markDirty() // Disabled: Using socket-based sync
},
clear: () => {

View File

@@ -43,7 +43,10 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
request: {
url: (params: JiraRetrieveBulkParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/picker?currentJQL=project=${params.projectId}`
const base = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/search`
// Don't encode JQL here - transformResponse will handle project resolution
// Initial page; transformResponse will paginate to retrieve all (with a safety cap)
return `${base}?maxResults=100&startAt=0&fields=summary,description,created,updated`
}
// If no cloudId, use the accessible resources endpoint
return 'https://api.atlassian.com/oauth/token/accessible-resources'
@@ -57,7 +60,40 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
},
transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => {
// If we don't have a cloudId, we need to fetch it first
const MAX_TOTAL = 1000
const PAGE_SIZE = 100
// Helper to extract description text safely (ADF can be nested)
const extractDescription = (desc: any): string => {
try {
return (
desc?.content?.[0]?.content?.[0]?.text ||
desc?.content?.flatMap((c: any) => c?.content || [])?.find((c: any) => c?.text)?.text ||
''
)
} catch (_e) {
return ''
}
}
// Helper to resolve a project reference (id or key) to its canonical key
const resolveProjectKey = async (cloudId: string, accessToken: string, ref: string) => {
const refTrimmed = (ref || '').trim()
if (!refTrimmed) return refTrimmed
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(refTrimmed)}`
const resp = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
})
if (!resp.ok) {
// If can't resolve, fall back to original ref (JQL can still work with id or key)
return refTrimmed
}
const project = await resp.json()
return project?.key || refTrimmed
}
// If we don't have a cloudId, look it up first
if (!params?.cloudId) {
const accessibleResources = await response.json()
const normalizedInput = `https://${params?.domain}`.toLowerCase()
@@ -65,99 +101,89 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
(r: any) => r.url.toLowerCase() === normalizedInput
)
// First get issue keys from picker
const pickerUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/picker?currentJQL=project=${params?.projectId}`
const pickerResponse = await fetch(pickerUrl, {
const base = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/search`
const projectKey = await resolveProjectKey(
matchedResource.id,
params!.accessToken,
params!.projectId
)
const jql = encodeURIComponent(`project=${projectKey} ORDER BY updated DESC`)
let startAt = 0
let collected: any[] = []
let total = 0
while (startAt < MAX_TOTAL) {
const url = `${base}?jql=${jql}&maxResults=${PAGE_SIZE}&startAt=${startAt}&fields=summary,description,created,updated`
const pageResponse = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${params?.accessToken}`,
Accept: 'application/json',
},
})
const pageData = await pageResponse.json()
const issues = pageData.issues || []
total = pageData.total || issues.length
collected = collected.concat(issues)
if (collected.length >= Math.min(total, MAX_TOTAL) || issues.length === 0) break
startAt += PAGE_SIZE
}
return {
success: true,
output: collected.slice(0, MAX_TOTAL).map((issue: any) => ({
ts: new Date().toISOString(),
summary: issue.fields?.summary,
description: extractDescription(issue.fields?.description),
created: issue.fields?.created,
updated: issue.fields?.updated,
})),
}
}
// cloudId present: resolve project and paginate using the Search API
// Resolve to canonical project key for consistent JQL
const projectKey = await resolveProjectKey(
params!.cloudId!,
params!.accessToken,
params!.projectId
)
const base = `https://api.atlassian.com/ex/jira/${params?.cloudId}/rest/api/3/search`
const jql = encodeURIComponent(`project=${projectKey} ORDER BY updated DESC`)
// Always do full pagination with resolved key
let collected: any[] = []
let total = 0
let startAt = 0
while (startAt < MAX_TOTAL) {
const url = `${base}?jql=${jql}&maxResults=${PAGE_SIZE}&startAt=${startAt}&fields=summary,description,created,updated`
const pageResponse = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${params?.accessToken}`,
Accept: 'application/json',
},
})
const pickerData = await pickerResponse.json()
const issueKeys = pickerData.sections
.flatMap((section: any) => section.issues || [])
.map((issue: any) => issue.key)
if (issueKeys.length === 0) {
return {
success: true,
output: [],
}
}
// Now use bulkfetch to get the full issue details
const bulkfetchUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/bulkfetch`
const bulkfetchResponse = await fetch(bulkfetchUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${params?.accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
expand: ['names'],
fields: ['summary', 'description', 'created', 'updated'],
fieldsByKeys: false,
issueIdsOrKeys: issueKeys,
properties: [],
}),
})
const data = await bulkfetchResponse.json()
return {
success: true,
output: data.issues.map((issue: any) => ({
ts: new Date().toISOString(),
summary: issue.fields.summary,
description: issue.fields.description?.content?.[0]?.content?.[0]?.text || '',
created: issue.fields.created,
updated: issue.fields.updated,
})),
}
const pageData = await pageResponse.json()
const issues = pageData.issues || []
total = pageData.total || issues.length
collected = collected.concat(issues)
if (issues.length === 0 || collected.length >= Math.min(total, MAX_TOTAL)) break
startAt += PAGE_SIZE
}
// If we have a cloudId, this response is from the issue picker
const pickerData = await response.json()
const issueKeys = pickerData.sections
.flatMap((section: any) => section.issues || [])
.map((issue: any) => issue.key)
if (issueKeys.length === 0) {
return {
success: true,
output: [],
}
}
// Use bulkfetch to get the full issue details
const bulkfetchUrl = `https://api.atlassian.com/ex/jira/${params?.cloudId}/rest/api/3/issue/bulkfetch`
const bulkfetchResponse = await fetch(bulkfetchUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${params?.accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
expand: ['names'],
fields: ['summary', 'description', 'created', 'updated'],
fieldsByKeys: false,
issueIdsOrKeys: issueKeys,
properties: [],
}),
})
const data = await bulkfetchResponse.json()
return {
success: true,
output: data.issues.map((issue: any) => ({
output: collected.slice(0, MAX_TOTAL).map((issue: any) => ({
ts: new Date().toISOString(),
summary: issue.fields.summary,
description: issue.fields.description?.content?.[0]?.content?.[0]?.text || '',
created: issue.fields.created,
updated: issue.fields.updated,
summary: issue.fields?.summary,
description: extractDescription(issue.fields?.description),
created: issue.fields?.created,
updated: issue.fields?.updated,
})),
}
},

View File

@@ -1,4 +1,5 @@
import type { JiraRetrieveParams, JiraRetrieveResponse } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveResponse> = {
@@ -30,8 +31,7 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
type: 'string',
required: false,
visibility: 'user-only',
description:
'Jira project ID to retrieve issues from. If not provided, all issues will be retrieved.',
description: 'Jira project ID (optional; not required to retrieve a single issue).',
},
issueKey: {
type: 'string',
@@ -66,16 +66,17 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
},
transformResponse: async (response: Response, params?: JiraRetrieveParams) => {
// If we don't have a cloudId, we need to fetch it first
if (!params?.cloudId) {
const accessibleResources = await response.json()
const normalizedInput = `https://${params?.domain}`.toLowerCase()
const matchedResource = accessibleResources.find(
(r: any) => r.url.toLowerCase() === normalizedInput
if (!params?.issueKey) {
throw new Error(
'Select a project to read issues, or provide an issue key to read a single issue.'
)
}
// If we don't have a cloudId, resolve it robustly using the Jira utils helper
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
// Now fetch the actual issue with the found cloudId
const issueUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/${params?.issueKey}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog`
const issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params?.issueKey}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog`
const issueResponse = await fetch(issueUrl, {
method: 'GET',
headers: {
@@ -84,31 +85,48 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
},
})
if (!issueResponse.ok) {
let message = `Failed to fetch Jira issue (${issueResponse.status})`
try {
const err = await issueResponse.json()
message = err?.message || err?.errorMessages?.[0] || message
} catch (_e) {}
throw new Error(message)
}
const data = await issueResponse.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
issueKey: data.key,
summary: data.fields.summary,
description: data.fields.description,
created: data.fields.created,
updated: data.fields.updated,
issueKey: data?.key,
summary: data?.fields?.summary,
description: data?.fields?.description,
created: data?.fields?.created,
updated: data?.fields?.updated,
},
}
}
// If we have a cloudId, this response is the issue data
if (!response.ok) {
let message = `Failed to fetch Jira issue (${response.status})`
try {
const err = await response.json()
message = err?.message || err?.errorMessages?.[0] || message
} catch (_e) {}
throw new Error(message)
}
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
issueKey: data.key,
summary: data.fields.summary,
description: data.fields.description,
created: data.fields.created,
updated: data.fields.updated,
issueKey: data?.key,
summary: data?.fields?.summary,
description: data?.fields?.description,
created: data?.fields?.created,
updated: data?.fields?.updated,
},
}
},

View File

@@ -4,7 +4,7 @@ export interface JiraRetrieveParams {
accessToken: string
issueKey: string
domain: string
cloudId: string
cloudId?: string
}
export interface JiraRetrieveResponse extends ToolResponse {
@@ -22,7 +22,7 @@ export interface JiraRetrieveBulkParams {
accessToken: string
domain: string
projectId: string
cloudId: string
cloudId?: string
}
export interface JiraRetrieveResponseBulk extends ToolResponse {