mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
v0.3.50: debounce moved server side, hasWorkflowChanged fixes, advanced mode/serializer fix, jira fix, billing notifs
This commit is contained in:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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) || ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
|
||||
// Construct parameters based on operation
|
||||
const baseParams = {
|
||||
accessToken: credential,
|
||||
credential,
|
||||
...rest,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }> }[]
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal file
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal 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
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal file
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal 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
|
||||
@@ -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={{
|
||||
|
||||
@@ -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')
|
||||
|
||||
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal file
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal 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";
|
||||
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,13 @@
|
||||
"when": 1757046301281,
|
||||
"tag": "0084_even_lockheed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1757348840739,
|
||||
"tag": "0085_daffy_blacklash",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user