From ebfdb9ce3b8a59a7370923e24154ba258389f0d8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 8 Sep 2025 15:23:15 -0700 Subject: [PATCH 01/15] V1 --- .../sim/app/api/workflows/[id]/state/route.ts | 32 ++- apps/sim/app/api/workflows/[id]/yaml/route.ts | 201 ++++++------------ apps/sim/lib/workflows/db-helpers.ts | 11 +- apps/sim/lib/workflows/validation.ts | 106 +++++++++ 4 files changed, 189 insertions(+), 161 deletions(-) create mode 100644 apps/sim/lib/workflows/validation.ts diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index a0afac7b24c..9fad5564121 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -7,6 +7,7 @@ import { getUserEntityPermissions } from '@/lib/permissions/utils' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' import { workflow } from '@/db/schema' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' const logger = createLogger('WorkflowStateAPI') @@ -168,11 +169,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Sanitize custom tools in agent blocks before saving + const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(state.blocks as any) + // Save to normalized tables // Ensure all required fields are present for WorkflowState type // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(state.blocks).reduce( - (acc, [blockId, block]) => { + const filteredBlocks = Object.entries(sanitizedBlocks).reduce( + (acc, [blockId, block]: [string, any]) => { if (block.type && block.name) { // Ensure all required fields are present acc[blockId] = { @@ -184,7 +188,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ height: block.height !== undefined ? block.height : 0, subBlocks: block.subBlocks || {}, outputs: block.outputs || {}, - data: block.data || {}, } } return acc @@ -227,29 +230,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) return NextResponse.json( - { - success: true, - blocksCount: Object.keys(filteredBlocks).length, - edgesCount: state.edges.length, - }, + { success: true, warnings }, { status: 200 } ) - } catch (error: unknown) { + } catch (error: any) { const elapsed = Date.now() - startTime - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow state data for ${workflowId}`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid state data', details: error.errors }, - { status: 400 } - ) - } - logger.error( `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, error ) + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request body', details: error.errors }, { status: 400 }) + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 86f7dc091f8..87922a6c5f2 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -1,39 +1,62 @@ +import crypto from 'crypto' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { getUserEntityPermissions } from '@/lib/permissions/utils' -import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent' -import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/db-helpers' -import { getUserId as getOAuthUserId } from '@/app/api/auth/oauth/utils' -import { getBlock } from '@/blocks' -import { getAllBlocks } from '@/blocks/registry' -import type { BlockConfig } from '@/blocks/types' -import { resolveOutputType } from '@/blocks/utils' import { db } from '@/db' -import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema' +import { workflow as workflowTable, workflowCheckpoints } from '@/db/schema' +import { getAllBlocks, getBlock } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import { getUserId } from '@/app/api/auth/oauth/utils' +import { simAgentClient } from '@/lib/sim-agent' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' +import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' -const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT - -export const dynamic = 'force-dynamic' - -const logger = createLogger('WorkflowYamlAPI') +const logger = createLogger('YamlWorkflowAPI') const YamlWorkflowRequestSchema = z.object({ yamlContent: z.string().min(1, 'YAML content is required'), description: z.string().optional(), - chatId: z.string().optional(), // For copilot checkpoints - source: z.enum(['copilot', 'import', 'editor']).default('editor'), - applyAutoLayout: z.boolean().default(true), - createCheckpoint: z.boolean().default(false), + chatId: z.string().optional(), + source: z.enum(['copilot', 'editor', 'import']).default('editor'), + applyAutoLayout: z.boolean().optional().default(false), + createCheckpoint: z.boolean().optional().default(false), }) -type YamlWorkflowRequest = z.infer +function updateBlockReferences( + value: any, + blockIdMapping: Map, + requestId: string +): any { + if (typeof value === 'string') { + // Replace references in string values + for (const [oldId, newId] of blockIdMapping.entries()) { + if (value.includes(oldId)) { + value = value + .replaceAll(`<${oldId}.`, `<${newId}.`) + .replaceAll(`%${oldId}.`, `%${newId}.`) + } + } + return value + } + + if (Array.isArray(value)) { + return value.map((item) => updateBlockReferences(item, blockIdMapping, requestId)) + } + + if (value && typeof value === 'object') { + const result: Record = {} + for (const [key, val] of Object.entries(value)) { + result[key] = updateBlockReferences(val, blockIdMapping, requestId) + } + return result + } + + return value +} /** * Helper function to create a checkpoint before workflow changes @@ -68,7 +91,7 @@ async function createWorkflowCheckpoint( {} as Record ) - const generateResponse = await fetch(`${SIM_AGENT_API_URL}/api/workflow/to-yaml`, { + const generateResponse = await fetch(`${env.SIM_AGENT_API_URL}/api/workflow/to-yaml`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -114,120 +137,6 @@ async function createWorkflowCheckpoint( } } -/** - * Helper function to get user ID with proper authentication for both tool calls and direct requests - */ -async function getUserId(requestId: string, workflowId: string): Promise { - // Use the OAuth utils function that handles both session and workflow-based auth - const userId = await getOAuthUserId(requestId, workflowId) - - if (!userId) { - logger.warn(`[${requestId}] Could not determine user ID for workflow ${workflowId}`) - return null - } - - // For additional security, verify the user has permission to access this workflow - const workflowData = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .then((rows) => rows[0]) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return null - } - - // Check if user has permission to update this workflow - let canUpdate = false - - // Case 1: User owns the workflow - if (workflowData.userId === userId) { - canUpdate = true - } - - // Case 2: Workflow belongs to a workspace and user has write or admin permission - if (!canUpdate && workflowData.workspaceId) { - try { - const userPermission = await getUserEntityPermissions( - userId, - 'workspace', - workflowData.workspaceId - ) - if (userPermission === 'write' || userPermission === 'admin') { - canUpdate = true - } - } catch (error) { - logger.warn(`[${requestId}] Error checking workspace permissions:`, error) - } - } - - if (!canUpdate) { - logger.warn(`[${requestId}] User ${userId} denied permission to update workflow ${workflowId}`) - return null - } - - return userId -} - -/** - * Helper function to update block references in values with new mapped IDs - */ -function updateBlockReferences( - value: any, - blockIdMapping: Map, - requestId: string -): any { - if (typeof value === 'string' && value.includes('<') && value.includes('>')) { - let processedValue = value - const blockMatches = value.match(/<([^>]+)>/g) - - if (blockMatches) { - for (const match of blockMatches) { - const path = match.slice(1, -1) - const [blockRef] = path.split('.') - - // Skip system references (start, loop, parallel, variable) - if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { - continue - } - - // Check if this references an old block ID that needs mapping - const newMappedId = blockIdMapping.get(blockRef) - if (newMappedId) { - logger.info(`[${requestId}] Updating block reference: ${blockRef} -> ${newMappedId}`) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}\\.`, 'g'), - `<${newMappedId}.` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}>`, 'g'), - `<${newMappedId}>` - ) - } - } - } - - return processedValue - } - - // Handle arrays - if (Array.isArray(value)) { - return value.map((item) => updateBlockReferences(item, blockIdMapping, requestId)) - } - - // Handle objects - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateBlockReferences(result[key], blockIdMapping, requestId) - } - return result - } - - return value -} - /** * PUT /api/workflows/[id]/yaml * Consolidated YAML workflow saving endpoint @@ -281,7 +190,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ {} as Record ) - const conversionResponse = await fetch(`${SIM_AGENT_API_URL}/api/yaml/to-workflow`, { + const conversionResponse = await fetch(`${env.SIM_AGENT_API_URL}/api/yaml/to-workflow`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -541,7 +450,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } - // Debug: Log block parent-child relationships before generating loops // Generate loop and parallel configurations const loops = generateLoopBlocks(newWorkflowState.blocks) const parallels = generateParallelBlocks(newWorkflowState.blocks) @@ -626,6 +534,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } + // Sanitize custom tools in agent blocks before saving + const { blocks: sanitizedBlocks, warnings: sanitationWarnings } = sanitizeAgentToolsInBlocks( + newWorkflowState.blocks + ) + if (sanitationWarnings.length > 0) { + logger.warn( + `[${requestId}] Tool sanitation produced ${sanitationWarnings.length} warning(s)` + ) + } + newWorkflowState.blocks = sanitizedBlocks + // Save to database const saveResult = await saveWorkflowToNormalizedTables(workflowId, newWorkflowState) @@ -635,7 +554,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ success: false, message: `Database save failed: ${saveResult.error || 'Unknown error'}`, errors: [saveResult.error || 'Database save failed'], - warnings, + warnings: [...warnings, ...sanitationWarnings], }) } @@ -687,7 +606,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ parallelsCount: Object.keys(parallels).length, }, errors: [], - warnings, + warnings: [...warnings, ...sanitationWarnings], }) } catch (error) { const elapsed = Date.now() - startTime diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index 8e2bee03ac0..2e715a4bf79 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -4,6 +4,7 @@ import { db } from '@/db' import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' const logger = createLogger('WorkflowDBHelpers') @@ -114,6 +115,14 @@ export async function loadWorkflowFromNormalizedTables( } }) + // Sanitize any invalid custom tools in agent blocks to prevent client crashes + const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(blocksMap) + if (warnings.length > 0) { + logger.warn(`Sanitized workflow ${workflowId} tools with ${warnings.length} warning(s)`, { + warnings, + }) + } + // Convert edges to the expected format const edgesArray = edges.map((edge) => ({ id: edge.id, @@ -146,7 +155,7 @@ export async function loadWorkflowFromNormalizedTables( }) return { - blocks: blocksMap, + blocks: sanitizedBlocks, edges: edgesArray, loops, parallels, diff --git a/apps/sim/lib/workflows/validation.ts b/apps/sim/lib/workflows/validation.ts new file mode 100644 index 00000000000..6c17ffebbb5 --- /dev/null +++ b/apps/sim/lib/workflows/validation.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('WorkflowValidation') + +function isValidCustomToolSchema(tool: any): boolean { + try { + if (!tool || typeof tool !== 'object') return false + if (tool.type !== 'custom-tool') return true // non-custom tools are validated elsewhere + + const schema = tool.schema + if (!schema || typeof schema !== 'object') return false + const fn = schema.function + if (!fn || typeof fn !== 'object') return false + if (!fn.name || typeof fn.name !== 'string') return false + + const params = fn.parameters + if (!params || typeof params !== 'object') return false + if (params.type !== 'object') return false + if (!params.properties || typeof params.properties !== 'object') return false + + return true + } catch (_err) { + return false + } +} + +export function sanitizeAgentToolsInBlocks( + blocks: Record +): { blocks: Record; warnings: string[] } { + const warnings: string[] = [] + + // Shallow clone to avoid mutating callers + const sanitizedBlocks: Record = { ...blocks } + + for (const [blockId, block] of Object.entries(sanitizedBlocks)) { + try { + if (!block || block.type !== 'agent') continue + const subBlocks = block.subBlocks || {} + const toolsSubBlock = subBlocks.tools + if (!toolsSubBlock) continue + + let value = toolsSubBlock.value + + // Parse legacy string format + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch (_e) { + warnings.push(`Block ${block.name || blockId}: invalid tools JSON; resetting tools to empty array`) + value = [] + } + } + + if (!Array.isArray(value)) { + // Force to array to keep client safe + warnings.push(`Block ${block.name || blockId}: tools value is not an array; resetting`) + toolsSubBlock.value = [] + continue + } + + const originalLength = value.length + const cleaned = value + .filter((tool: any) => { + // Allow non-custom tools to pass through as-is + if (!tool || typeof tool !== 'object') return false + if (tool.type !== 'custom-tool') return true + const ok = isValidCustomToolSchema(tool) + if (!ok) { + logger.warn('Removing invalid custom tool from workflow', { + blockId, + blockName: block.name, + }) + } + return ok + }) + .map((tool: any) => { + if (tool.type === 'custom-tool') { + // Ensure required defaults to avoid client crashes + if (!tool.code || typeof tool.code !== 'string') { + tool.code = '' + } + if (!tool.usageControl) { + tool.usageControl = 'auto' + } + } + return tool + }) + + if (cleaned.length !== originalLength) { + warnings.push( + `Block ${block.name || blockId}: removed ${originalLength - cleaned.length} invalid tool(s)` + ) + } + + toolsSubBlock.value = cleaned + // Reassign in case caller uses object identity + sanitizedBlocks[blockId] = { ...block, subBlocks: { ...subBlocks, tools: toolsSubBlock } } + } catch (err: any) { + warnings.push( + `Block ${block?.name || blockId}: tools sanitation failed: ${err?.message || String(err)}` + ) + } + } + + return { blocks: sanitizedBlocks, warnings } +} \ No newline at end of file From adf8c2244c6d357d6c50cfa8c4ef855992342c57 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 8 Sep 2025 15:46:59 -0700 Subject: [PATCH 02/15] Fix custom tool save --- apps/sim/app/api/workflows/[id]/yaml/route.ts | 111 +++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 87922a6c5f2..980538fc8b8 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { workflow as workflowTable, workflowCheckpoints } from '@/db/schema' +import { workflow as workflowTable, workflowCheckpoints, customTools } from '@/db/schema' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' @@ -137,6 +137,105 @@ async function createWorkflowCheckpoint( } } +async function upsertCustomToolsFromBlocks( + userId: string, + blocks: Record, + requestId: string +): Promise<{ created: number; updated: number }> { + try { + // Collect custom tools from all agent blocks + const collected: Array<{ title: string; schema: any; code: string }> = [] + + for (const block of Object.values(blocks)) { + if (!block || block.type !== 'agent') continue + const toolsSub = block.subBlocks?.tools + if (!toolsSub) continue + + let value = toolsSub.value + if (!value) continue + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + continue + } + } + if (!Array.isArray(value)) continue + + for (const tool of value) { + if ( + tool && + tool.type === 'custom-tool' && + tool.schema && + tool.schema.function && + tool.schema.function.name && + typeof tool.code === 'string' + ) { + collected.push({ title: tool.title || tool.schema.function.name, schema: tool.schema, code: tool.code }) + } + } + } + + if (collected.length === 0) return { created: 0, updated: 0 } + + // Ensure unique by function name + const byName = new Map() + for (const t of collected) { + const name = t.schema.function.name + if (!byName.has(name)) byName.set(name, t) + } + + // Load existing user's tools + const existing = await db + .select() + .from(customTools) + .where(eq(customTools.userId, userId)) + + const existingByName = new Map() + for (const row of existing) { + try { + const fnName = (row.schema as any)?.function?.name + if (fnName) existingByName.set(fnName, row as any) + } catch {} + } + + let created = 0 + let updated = 0 + const now = new Date() + + // Upsert by function name + for (const [name, tool] of byName.entries()) { + const match = existingByName.get(name) + if (!match) { + await db.insert(customTools).values({ + id: crypto.randomUUID(), + userId, + title: tool.title, + schema: tool.schema, + code: tool.code, + createdAt: now, + updatedAt: now, + }) + created++ + } else { + await db + .update(customTools) + .set({ title: tool.title, schema: tool.schema, code: tool.code, updatedAt: now }) + .where(eq(customTools.id, match.id)) + updated++ + } + } + + logger.info(`[${requestId}] Upserted custom tools from YAML`, { created, updated }) + return { created, updated } + } catch (err) { + logger.warn(`[${requestId}] Failed to upsert custom tools from YAML`, { + error: err instanceof Error ? err.message : String(err), + }) + return { created: 0, updated: 0 } + } +} + /** * PUT /api/workflows/[id]/yaml * Consolidated YAML workflow saving endpoint @@ -226,7 +325,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const conversionResult = await conversionResponse.json() - if (!conversionResult.success || !conversionResult.workflowState) { + const workflowState = + conversionResult.workflowState || (conversionResult.diff && conversionResult.diff.proposedState) + + if (!conversionResult.success || !workflowState) { logger.error(`[${requestId}] YAML conversion failed`, { errors: conversionResult.errors, warnings: conversionResult.warnings, @@ -239,8 +341,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) } - const { workflowState } = conversionResult - // Ensure all blocks have required fields Object.values(workflowState.blocks).forEach((block: any) => { if (block.enabled === undefined) { @@ -545,6 +645,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } newWorkflowState.blocks = sanitizedBlocks + // Upsert custom tools from blocks + await upsertCustomToolsFromBlocks(userId, newWorkflowState.blocks, requestId) + // Save to database const saveResult = await saveWorkflowToNormalizedTables(workflowId, newWorkflowState) From d3572800032528bbb49921842632de6fe5d8e3d8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 8 Sep 2025 16:35:58 -0700 Subject: [PATCH 03/15] feat(usage-api): make external endpoint to query usage (#1285) * feat(usage-api): make external endpoint to query usage * add docs * consolidate endpoints with rate-limits one * update docs * consolidate code * remove unused route --- apps/docs/content/docs/execution/advanced.mdx | 44 +++++++++++ apps/sim/app/api/users/me/rate-limit/route.ts | 79 ------------------- .../app/api/users/me/usage-limits/route.ts | 74 +++++++++++++++++ .../example-command/example-command.tsx | 4 +- apps/sim/lib/billing/core/usage.ts | 43 +++++++++- 5 files changed, 162 insertions(+), 82 deletions(-) delete mode 100644 apps/sim/app/api/users/me/rate-limit/route.ts create mode 100644 apps/sim/app/api/users/me/usage-limits/route.ts diff --git a/apps/docs/content/docs/execution/advanced.mdx b/apps/docs/content/docs/execution/advanced.mdx index a928211016e..49e3e87fd59 100644 --- a/apps/docs/content/docs/execution/advanced.mdx +++ b/apps/docs/content/docs/execution/advanced.mdx @@ -212,3 +212,47 @@ Monitor your usage and billing in Settings → Subscription: - **Usage Limits**: Plan limits with visual progress indicators - **Billing Details**: Projected charges and minimum commitments - **Plan Management**: Upgrade options and billing history + +### Programmatic Rate Limits & Usage (API) + +You can query your current API rate limits and usage summary using your API key. + +Endpoint: + +```text +GET /api/users/me/usage-limits +``` + +Authentication: + +- Include your API key in the `X-API-Key` header. + +Response (example): + +```json +{ + "success": true, + "rateLimit": { + "sync": { "isLimited": false, "limit": 10, "remaining": 10, "resetAt": "2025-09-08T22:51:55.999Z" }, + "async": { "isLimited": false, "limit": 50, "remaining": 50, "resetAt": "2025-09-08T22:51:56.155Z" }, + "authType": "api" + }, + "usage": { + "currentPeriodCost": 12.34, + "limit": 100, + "plan": "pro" + } +} +``` + +Example: + +```bash +curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits +``` + +Notes: + +- `currentPeriodCost` reflects usage in the current billing period. +- `limit` is derived from individual limits (Free/Pro) or pooled organization limits (Team/Enterprise). +- `plan` is the highest-priority active plan associated with your user. diff --git a/apps/sim/app/api/users/me/rate-limit/route.ts b/apps/sim/app/api/users/me/rate-limit/route.ts deleted file mode 100644 index 62167b381d8..00000000000 --- a/apps/sim/app/api/users/me/rate-limit/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { createLogger } from '@/lib/logs/console/logger' -import { createErrorResponse } from '@/app/api/workflows/utils' -import { db } from '@/db' -import { apiKey as apiKeyTable } from '@/db/schema' -import { RateLimiter } from '@/services/queue' - -const logger = createLogger('RateLimitAPI') - -export async function GET(request: NextRequest) { - try { - const session = await getSession() - let authenticatedUserId: string | null = session?.user?.id || null - - if (!authenticatedUserId) { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - const [apiKeyRecord] = await db - .select({ userId: apiKeyTable.userId }) - .from(apiKeyTable) - .where(eq(apiKeyTable.key, apiKeyHeader)) - .limit(1) - - if (apiKeyRecord) { - authenticatedUserId = apiKeyRecord.userId - } - } - } - - if (!authenticatedUserId) { - return createErrorResponse('Authentication required', 401) - } - - // Get user subscription (checks both personal and org subscriptions) - const userSubscription = await getHighestPrioritySubscription(authenticatedUserId) - - const rateLimiter = new RateLimiter() - const isApiAuth = !session?.user?.id - const triggerType = isApiAuth ? 'api' : 'manual' - - const syncStatus = await rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - false - ) - const asyncStatus = await rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - true - ) - - return NextResponse.json({ - success: true, - rateLimit: { - sync: { - isLimited: syncStatus.remaining === 0, - limit: syncStatus.limit, - remaining: syncStatus.remaining, - resetAt: syncStatus.resetAt, - }, - async: { - isLimited: asyncStatus.remaining === 0, - limit: asyncStatus.limit, - remaining: asyncStatus.remaining, - resetAt: asyncStatus.resetAt, - }, - authType: triggerType, - }, - }) - } catch (error: any) { - logger.error('Error checking rate limit:', error) - return createErrorResponse(error.message || 'Failed to check rate limit', 500) - } -} diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts new file mode 100644 index 00000000000..df8804feef5 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' +import { createLogger } from '@/lib/logs/console/logger' +import { createErrorResponse } from '@/app/api/workflows/utils' +import { RateLimiter } from '@/services/queue' + +const logger = createLogger('UsageLimitsAPI') + +export async function GET(request: NextRequest) { + try { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return createErrorResponse('Authentication required', 401) + } + const authenticatedUserId = auth.userId + + // Rate limit info (sync + async), mirroring /users/me/rate-limit + const userSubscription = await getHighestPrioritySubscription(authenticatedUserId) + const rateLimiter = new RateLimiter() + const triggerType = auth.authType === 'api_key' ? 'api' : 'manual' + const [syncStatus, asyncStatus] = await Promise.all([ + rateLimiter.getRateLimitStatusWithSubscription( + authenticatedUserId, + userSubscription, + triggerType, + false + ), + rateLimiter.getRateLimitStatusWithSubscription( + authenticatedUserId, + userSubscription, + triggerType, + true + ), + ]) + + // Usage summary (current period cost + limit + plan) + const [usageCheck, effectiveCost] = await Promise.all([ + checkServerSideUsageLimits(authenticatedUserId), + getEffectiveCurrentPeriodCost(authenticatedUserId), + ]) + + const currentPeriodCost = effectiveCost + + return NextResponse.json({ + success: true, + rateLimit: { + sync: { + isLimited: syncStatus.remaining === 0, + limit: syncStatus.limit, + remaining: syncStatus.remaining, + resetAt: syncStatus.resetAt, + }, + async: { + isLimited: asyncStatus.remaining === 0, + limit: asyncStatus.limit, + remaining: asyncStatus.remaining, + resetAt: asyncStatus.resetAt, + }, + authType: triggerType, + }, + usage: { + currentPeriodCost, + limit: usageCheck.limit, + plan: userSubscription?.plan || 'free', + }, + }) + } catch (error: any) { + logger.error('Error checking usage limits:', error) + return createErrorResponse(error.message || 'Failed to check usage limits', 500) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx index fd2b7d677b5..a7c5014cc18 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx @@ -79,7 +79,7 @@ export function ExampleCommand({ case 'rate-limits': { const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0] return `curl -H "X-API-Key: ${apiKey}" \\ - ${baseUrlForRateLimit}/api/users/me/rate-limit` + ${baseUrlForRateLimit}/api/users/me/usage-limits` } default: @@ -119,7 +119,7 @@ export function ExampleCommand({ case 'rate-limits': { const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0] return `curl -H "X-API-Key: SIM_API_KEY" \\ - ${baseUrlForRateLimit}/api/users/me/rate-limit` + ${baseUrlForRateLimit}/api/users/me/usage-limits` } default: diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index edf24b542cf..738fd5a749c 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { @@ -490,6 +490,47 @@ export async function getTeamUsageLimits(organizationId: string): Promise< } } +/** + * Returns the effective current period usage cost for a user. + * - Free/Pro: user's own currentPeriodCost (fallback to totalCost) + * - Team/Enterprise: pooled sum of all members' currentPeriodCost within the organization + */ +export async function getEffectiveCurrentPeriodCost(userId: string): Promise { + const subscription = await getHighestPrioritySubscription(userId) + + // If no team/org subscription, return the user's own usage + if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') { + const rows = await db + .select({ current: userStats.currentPeriodCost }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + + if (rows.length === 0) return 0 + return rows[0].current ? Number.parseFloat(rows[0].current.toString()) : 0 + } + + // Team/Enterprise: pooled usage across org members + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + + if (teamMembers.length === 0) return 0 + + const memberIds = teamMembers.map((m) => m.userId) + const rows = await db + .select({ current: userStats.currentPeriodCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + let pooled = 0 + for (const r of rows) { + pooled += r.current ? Number.parseFloat(r.current.toString()) : 0 + } + return pooled +} + /** * Calculate billing projection based on current usage */ From 521316bb8cf7e5eba0eff0a8c0647fc3dd86a92b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 8 Sep 2025 16:39:57 -0700 Subject: [PATCH 04/15] Lint --- .../sim/app/api/workflows/[id]/state/route.ts | 12 +++--- apps/sim/app/api/workflows/[id]/yaml/route.ts | 39 +++++++++---------- apps/sim/lib/workflows/db-helpers.ts | 2 +- apps/sim/lib/workflows/validation.ts | 11 ++++-- 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 9fad5564121..1bb1b2d5928 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -5,9 +5,9 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { db } from '@/db' import { workflow } from '@/db/schema' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' const logger = createLogger('WorkflowStateAPI') @@ -229,10 +229,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) - return NextResponse.json( - { success: true, warnings }, - { status: 200 } - ) + return NextResponse.json({ success: true, warnings }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime logger.error( @@ -241,7 +238,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request body', details: error.errors }, { status: 400 }) + return NextResponse.json( + { error: 'Invalid request body', details: error.errors }, + { status: 400 } + ) } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 980538fc8b8..96f17d89b3f 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -4,16 +4,19 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { workflow as workflowTable, workflowCheckpoints, customTools } from '@/db/schema' +import { simAgentClient } from '@/lib/sim-agent' +import { + loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' +import { getUserId } from '@/app/api/auth/oauth/utils' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { resolveOutputType } from '@/blocks/utils' -import { getUserId } from '@/app/api/auth/oauth/utils' -import { simAgentClient } from '@/lib/sim-agent' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { db } from '@/db' +import { customTools, workflowCheckpoints, workflow as workflowTable } from '@/db/schema' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' const logger = createLogger('YamlWorkflowAPI') @@ -35,9 +38,7 @@ function updateBlockReferences( // Replace references in string values for (const [oldId, newId] of blockIdMapping.entries()) { if (value.includes(oldId)) { - value = value - .replaceAll(`<${oldId}.`, `<${newId}.`) - .replaceAll(`%${oldId}.`, `%${newId}.`) + value = value.replaceAll(`<${oldId}.`, `<${newId}.`).replaceAll(`%${oldId}.`, `%${newId}.`) } } return value @@ -171,7 +172,11 @@ async function upsertCustomToolsFromBlocks( tool.schema.function.name && typeof tool.code === 'string' ) { - collected.push({ title: tool.title || tool.schema.function.name, schema: tool.schema, code: tool.code }) + collected.push({ + title: tool.title || tool.schema.function.name, + schema: tool.schema, + code: tool.code, + }) } } } @@ -186,10 +191,7 @@ async function upsertCustomToolsFromBlocks( } // Load existing user's tools - const existing = await db - .select() - .from(customTools) - .where(eq(customTools.userId, userId)) + const existing = await db.select().from(customTools).where(eq(customTools.userId, userId)) const existingByName = new Map() for (const row of existing) { @@ -325,8 +327,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const conversionResult = await conversionResponse.json() - const workflowState = - conversionResult.workflowState || (conversionResult.diff && conversionResult.diff.proposedState) + const workflowState = conversionResult.workflowState || conversionResult.diff?.proposedState if (!conversionResult.success || !workflowState) { logger.error(`[${requestId}] YAML conversion failed`, { @@ -639,9 +640,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ newWorkflowState.blocks ) if (sanitationWarnings.length > 0) { - logger.warn( - `[${requestId}] Tool sanitation produced ${sanitationWarnings.length} warning(s)` - ) + logger.warn(`[${requestId}] Tool sanitation produced ${sanitationWarnings.length} warning(s)`) } newWorkflowState.blocks = sanitizedBlocks diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index 2e715a4bf79..18e8af6b5bd 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -1,10 +1,10 @@ import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console/logger' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { db } from '@/db' import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' const logger = createLogger('WorkflowDBHelpers') diff --git a/apps/sim/lib/workflows/validation.ts b/apps/sim/lib/workflows/validation.ts index 6c17ffebbb5..e09abf3aeb2 100644 --- a/apps/sim/lib/workflows/validation.ts +++ b/apps/sim/lib/workflows/validation.ts @@ -24,9 +24,10 @@ function isValidCustomToolSchema(tool: any): boolean { } } -export function sanitizeAgentToolsInBlocks( +export function sanitizeAgentToolsInBlocks(blocks: Record): { blocks: Record -): { blocks: Record; warnings: string[] } { + warnings: string[] +} { const warnings: string[] = [] // Shallow clone to avoid mutating callers @@ -46,7 +47,9 @@ export function sanitizeAgentToolsInBlocks( try { value = JSON.parse(value) } catch (_e) { - warnings.push(`Block ${block.name || blockId}: invalid tools JSON; resetting tools to empty array`) + warnings.push( + `Block ${block.name || blockId}: invalid tools JSON; resetting tools to empty array` + ) value = [] } } @@ -103,4 +106,4 @@ export function sanitizeAgentToolsInBlocks( } return { blocks: sanitizedBlocks, warnings } -} \ No newline at end of file +} From 0785f6e920474ce5d5fff14341adbad165447a90 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 9 Sep 2025 11:34:18 -0700 Subject: [PATCH 05/15] feat(logs-api): expose logs as api + can subscribe to workflow execution using webhook url (#1287) * feat(logs-api): expose logs as api + can subscribe to workflow exection using webhook url * fix scroll * Update apps/docs/content/docs/execution/api.mdx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix rate limits * address greptile comments * remove unused file * address more greptile comments * minor UI changes * fix atomicity to prevent races * make search param sensible --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- apps/docs/content/docs/execution/api.mdx | 532 ++ apps/docs/content/docs/execution/meta.json | 2 +- apps/sim/app/api/v1/auth.ts | 64 + apps/sim/app/api/v1/logs/[id]/route.ts | 106 + .../v1/logs/executions/[executionId]/route.ts | 100 + apps/sim/app/api/v1/logs/filters.ts | 110 + apps/sim/app/api/v1/logs/meta.ts | 78 + apps/sim/app/api/v1/logs/route.ts | 212 + apps/sim/app/api/v1/middleware.ts | 108 + .../[id]/log-webhook/[webhookId]/route.ts | 221 + .../api/workflows/[id]/log-webhook/route.ts | 248 + .../workflows/[id]/log-webhook/test/route.ts | 233 + .../control-bar/components/index.ts | 1 + .../webhook-settings/webhook-settings.tsx | 799 +++ .../components/control-bar/control-bar.tsx | 49 + apps/sim/background/logs-webhook-delivery.ts | 404 ++ .../migrations/0086_breezy_sister_grimm.sql | 43 + .../sim/db/migrations/meta/0086_snapshot.json | 6353 +++++++++++++++++ apps/sim/db/migrations/meta/_journal.json | 7 + apps/sim/db/schema.ts | 70 + apps/sim/lib/logs/events.ts | 101 + apps/sim/lib/logs/execution/logger.ts | 12 +- apps/sim/services/queue/RateLimiter.ts | 90 +- apps/sim/services/queue/types.ts | 12 +- 24 files changed, 9929 insertions(+), 26 deletions(-) create mode 100644 apps/docs/content/docs/execution/api.mdx create mode 100644 apps/sim/app/api/v1/auth.ts create mode 100644 apps/sim/app/api/v1/logs/[id]/route.ts create mode 100644 apps/sim/app/api/v1/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v1/logs/filters.ts create mode 100644 apps/sim/app/api/v1/logs/meta.ts create mode 100644 apps/sim/app/api/v1/logs/route.ts create mode 100644 apps/sim/app/api/v1/middleware.ts create mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts create mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/route.ts create mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/webhook-settings/webhook-settings.tsx create mode 100644 apps/sim/background/logs-webhook-delivery.ts create mode 100644 apps/sim/db/migrations/0086_breezy_sister_grimm.sql create mode 100644 apps/sim/db/migrations/meta/0086_snapshot.json create mode 100644 apps/sim/lib/logs/events.ts diff --git a/apps/docs/content/docs/execution/api.mdx b/apps/docs/content/docs/execution/api.mdx new file mode 100644 index 00000000000..d1c219d049e --- /dev/null +++ b/apps/docs/content/docs/execution/api.mdx @@ -0,0 +1,532 @@ +--- +title: External API +description: Query workflow execution logs and set up webhooks for real-time notifications +--- + +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion' +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { CodeBlock } from 'fumadocs-ui/components/codeblock' + +Sim provides a comprehensive external API for querying workflow execution logs and setting up webhooks for real-time notifications when workflows complete. + +## Authentication + +All API requests require an API key passed in the `x-api-key` header: + +```bash +curl -H "x-api-key: YOUR_API_KEY" \ + https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID +``` + +You can generate API keys from your user settings in the Sim dashboard. + +## Logs API + +All API responses include information about your workflow execution limits and usage: + +```json +"limits": { + "workflowExecutionRateLimit": { + "sync": { + "limit": 60, // Max sync workflow executions per minute + "remaining": 58, // Remaining sync workflow executions + "resetAt": "..." // When the window resets + }, + "async": { + "limit": 60, // Max async workflow executions per minute + "remaining": 59, // Remaining async workflow executions + "resetAt": "..." // When the window resets + } + }, + "usage": { + "currentPeriodCost": 1.234, // Current billing period usage in USD + "limit": 10, // Usage limit in USD + "plan": "pro", // Current subscription plan + "isExceeded": false // Whether limit is exceeded + } +} +``` + +**Note:** The rate limits in the response body are for workflow executions. The rate limits for calling this API endpoint are in the response headers (`X-RateLimit-*`). + +### Query Logs + +Query workflow execution logs with extensive filtering options. + + + + ```http + GET /api/v1/logs + ``` + + **Required Parameters:** + - `workspaceId` - Your workspace ID + + **Optional Filters:** + - `workflowIds` - Comma-separated workflow IDs + - `folderIds` - Comma-separated folder IDs + - `triggers` - Comma-separated trigger types: `api`, `webhook`, `schedule`, `manual`, `chat` + - `level` - Filter by level: `info`, `error` + - `startDate` - ISO timestamp for date range start + - `endDate` - ISO timestamp for date range end + - `executionId` - Exact execution ID match + - `minDurationMs` - Minimum execution duration in milliseconds + - `maxDurationMs` - Maximum execution duration in milliseconds + - `minCost` - Minimum execution cost + - `maxCost` - Maximum execution cost + - `model` - Filter by AI model used + + **Pagination:** + - `limit` - Results per page (default: 100) + - `cursor` - Cursor for next page + - `order` - Sort order: `desc`, `asc` (default: desc) + + **Detail Level:** + - `details` - Response detail level: `basic`, `full` (default: basic) + - `includeTraceSpans` - Include trace spans (default: false) + - `includeFinalOutput` - Include final output (default: false) + + + ```json + { + "data": [ + { + "id": "log_abc123", + "workflowId": "wf_xyz789", + "executionId": "exec_def456", + "level": "info", + "trigger": "api", + "startedAt": "2025-01-01T12:34:56.789Z", + "endedAt": "2025-01-01T12:34:57.123Z", + "totalDurationMs": 334, + "cost": { + "total": 0.00234 + }, + "files": null + } + ], + "nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0", + "limits": { + "workflowExecutionRateLimit": { + "sync": { + "limit": 60, + "remaining": 58, + "resetAt": "2025-01-01T12:35:56.789Z" + }, + "async": { + "limit": 60, + "remaining": 59, + "resetAt": "2025-01-01T12:35:56.789Z" + } + }, + "usage": { + "currentPeriodCost": 1.234, + "limit": 10, + "plan": "pro", + "isExceeded": false + } + } + } + ``` + + + +### Get Log Details + +Retrieve detailed information about a specific log entry. + + + + ```http + GET /api/v1/logs/{id} + ``` + + + ```json + { + "data": { + "id": "log_abc123", + "workflowId": "wf_xyz789", + "executionId": "exec_def456", + "level": "info", + "trigger": "api", + "startedAt": "2025-01-01T12:34:56.789Z", + "endedAt": "2025-01-01T12:34:57.123Z", + "totalDurationMs": 334, + "workflow": { + "id": "wf_xyz789", + "name": "My Workflow", + "description": "Process customer data" + }, + "executionData": { + "traceSpans": [...], + "finalOutput": {...} + }, + "cost": { + "total": 0.00234, + "tokens": { + "prompt": 123, + "completion": 456, + "total": 579 + }, + "models": { + "gpt-4o": { + "input": 0.001, + "output": 0.00134, + "total": 0.00234, + "tokens": { + "prompt": 123, + "completion": 456, + "total": 579 + } + } + } + }, + "limits": { + "workflowExecutionRateLimit": { + "sync": { + "limit": 60, + "remaining": 58, + "resetAt": "2025-01-01T12:35:56.789Z" + }, + "async": { + "limit": 60, + "remaining": 59, + "resetAt": "2025-01-01T12:35:56.789Z" + } + }, + "usage": { + "currentPeriodCost": 1.234, + "limit": 10, + "plan": "pro", + "isExceeded": false + } + } + } + } + ``` + + + +### Get Execution Details + +Retrieve execution details including the workflow state snapshot. + + + + ```http + GET /api/v1/logs/executions/{executionId} + ``` + + + ```json + { + "executionId": "exec_def456", + "workflowId": "wf_xyz789", + "workflowState": { + "blocks": {...}, + "edges": [...], + "loops": {...}, + "parallels": {...} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2025-01-01T12:34:56.789Z", + "endedAt": "2025-01-01T12:34:57.123Z", + "totalDurationMs": 334, + "cost": {...} + } + } + ``` + + + +## Webhook Subscriptions + +Get real-time notifications when workflow executions complete. Webhooks are configured through the Sim UI in the workflow editor. + +### Configuration + +Webhooks can be configured for each workflow through the workflow editor UI. Click the webhook icon in the control bar to set up your webhook subscriptions. + +**Available Configuration Options:** +- `url`: Your webhook endpoint URL +- `secret`: Optional secret for HMAC signature verification +- `includeFinalOutput`: Include the workflow's final output in the payload +- `includeTraceSpans`: Include detailed execution trace spans +- `includeRateLimits`: Include the workflow owner's rate limit information +- `includeUsageData`: Include the workflow owner's usage and billing data +- `levelFilter`: Array of log levels to receive (`info`, `error`) +- `triggerFilter`: Array of trigger types to receive (`api`, `webhook`, `schedule`, `manual`, `chat`) +- `active`: Enable/disable the webhook subscription + +### Webhook Payload + +When a workflow execution completes, Sim sends a POST request to your webhook URL: + +```json +{ + "id": "evt_123", + "type": "workflow.execution.completed", + "timestamp": 1735925767890, + "data": { + "workflowId": "wf_xyz789", + "executionId": "exec_def456", + "status": "success", + "level": "info", + "trigger": "api", + "startedAt": "2025-01-01T12:34:56.789Z", + "endedAt": "2025-01-01T12:34:57.123Z", + "totalDurationMs": 334, + "cost": { + "total": 0.00234, + "tokens": { + "prompt": 123, + "completion": 456, + "total": 579 + }, + "models": { + "gpt-4o": { + "input": 0.001, + "output": 0.00134, + "total": 0.00234, + "tokens": { + "prompt": 123, + "completion": 456, + "total": 579 + } + } + } + }, + "files": null, + "finalOutput": {...}, // Only if includeFinalOutput=true + "traceSpans": [...], // Only if includeTraceSpans=true + "rateLimits": {...}, // Only if includeRateLimits=true + "usage": {...} // Only if includeUsageData=true + }, + "links": { + "log": "/v1/logs/log_abc123", + "execution": "/v1/logs/executions/exec_def456" + } +} +``` + +### Webhook Headers + +Each webhook request includes these headers: + +- `sim-event`: Event type (always `workflow.execution.completed`) +- `sim-timestamp`: Unix timestamp in milliseconds +- `sim-delivery-id`: Unique delivery ID for idempotency +- `sim-signature`: HMAC-SHA256 signature for verification (if secret configured) +- `Idempotency-Key`: Same as delivery ID for duplicate detection + +### Signature Verification + +If you configure a webhook secret, verify the signature to ensure the webhook is from Sim: + + + + ```javascript + import crypto from 'crypto'; + + function verifyWebhookSignature(body, signature, secret) { + const [timestampPart, signaturePart] = signature.split(','); + const timestamp = timestampPart.replace('t=', ''); + const expectedSignature = signaturePart.replace('v1=', ''); + + const signatureBase = `${timestamp}.${body}`; + const hmac = crypto.createHmac('sha256', secret); + hmac.update(signatureBase); + const computedSignature = hmac.digest('hex'); + + return computedSignature === expectedSignature; + } + + // In your webhook handler + app.post('/webhook', (req, res) => { + const signature = req.headers['sim-signature']; + const body = JSON.stringify(req.body); + + if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET)) { + return res.status(401).send('Invalid signature'); + } + + // Process the webhook... + }); + ``` + + + ```python + import hmac + import hashlib + import json + + def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: + timestamp_part, signature_part = signature.split(',') + timestamp = timestamp_part.replace('t=', '') + expected_signature = signature_part.replace('v1=', '') + + signature_base = f"{timestamp}.{body}" + computed_signature = hmac.new( + secret.encode(), + signature_base.encode(), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(computed_signature, expected_signature) + + # In your webhook handler + @app.route('/webhook', methods=['POST']) + def webhook(): + signature = request.headers.get('sim-signature') + body = json.dumps(request.json) + + if not verify_webhook_signature(body, signature, os.environ['WEBHOOK_SECRET']): + return 'Invalid signature', 401 + + # Process the webhook... + ``` + + + +### Retry Policy + +Failed webhook deliveries are retried with exponential backoff and jitter: + +- Maximum attempts: 5 +- Retry delays: 5 seconds, 15 seconds, 1 minute, 3 minutes, 10 minutes +- Jitter: Up to 10% additional delay to prevent thundering herd +- Only HTTP 5xx and 429 responses trigger retries +- Deliveries timeout after 30 seconds + + + Webhook deliveries are processed asynchronously and don't affect workflow execution performance. + + +## Best Practices + +1. **Polling Strategy**: When polling for logs, use cursor-based pagination with `order=asc` and `startDate` to fetch new logs efficiently. + +2. **Webhook Security**: Always configure a webhook secret and verify signatures to ensure requests are from Sim. + +3. **Idempotency**: Use the `Idempotency-Key` header to detect and handle duplicate webhook deliveries. + +4. **Privacy**: By default, `finalOutput` and `traceSpans` are excluded from responses. Only enable these if you need the data and understand the privacy implications. + +5. **Rate Limiting**: Implement exponential backoff when you receive 429 responses. Check the `Retry-After` header for the recommended wait time. + +## Rate Limiting + +The API implements rate limiting to ensure fair usage: + +- **Free plan**: 10 requests per minute +- **Pro plan**: 30 requests per minute +- **Team plan**: 60 requests per minute +- **Enterprise plan**: Custom limits + +Rate limit information is included in response headers: +- `X-RateLimit-Limit`: Maximum requests per window +- `X-RateLimit-Remaining`: Requests remaining in current window +- `X-RateLimit-Reset`: ISO timestamp when the window resets + +## Example: Polling for New Logs + +```javascript +let cursor = null; +const workspaceId = 'YOUR_WORKSPACE_ID'; +const startDate = new Date().toISOString(); + +async function pollLogs() { + const params = new URLSearchParams({ + workspaceId, + startDate, + order: 'asc', + limit: '100' + }); + + if (cursor) { + params.append('cursor', cursor); + } + + const response = await fetch( + `https://sim.ai/api/v1/logs?${params}`, + { + headers: { + 'x-api-key': 'YOUR_API_KEY' + } + } + ); + + if (response.ok) { + const data = await response.json(); + + // Process new logs + for (const log of data.data) { + console.log(`New execution: ${log.executionId}`); + } + + // Update cursor for next poll + if (data.nextCursor) { + cursor = data.nextCursor; + } + } +} + +// Poll every 30 seconds +setInterval(pollLogs, 30000); +``` + +## Example: Processing Webhooks + +```javascript +import express from 'express'; +import crypto from 'crypto'; + +const app = express(); +app.use(express.json()); + +app.post('/sim-webhook', (req, res) => { + // Verify signature + const signature = req.headers['sim-signature']; + const body = JSON.stringify(req.body); + + if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET)) { + return res.status(401).send('Invalid signature'); + } + + // Check timestamp to prevent replay attacks + const timestamp = parseInt(req.headers['sim-timestamp']); + const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); + + if (timestamp < fiveMinutesAgo) { + return res.status(401).send('Timestamp too old'); + } + + // Process the webhook + const event = req.body; + + switch (event.type) { + case 'workflow.execution.completed': + const { workflowId, executionId, status, cost } = event.data; + + if (status === 'error') { + console.error(`Workflow ${workflowId} failed: ${executionId}`); + // Handle error... + } else { + console.log(`Workflow ${workflowId} completed: ${executionId}`); + console.log(`Cost: $${cost.total}`); + // Process successful execution... + } + break; + } + + // Return 200 to acknowledge receipt + res.status(200).send('OK'); +}); + +app.listen(3000, () => { + console.log('Webhook server listening on port 3000'); +}); +``` diff --git a/apps/docs/content/docs/execution/meta.json b/apps/docs/content/docs/execution/meta.json index f6bc4b0fd81..0d8d9d438b6 100644 --- a/apps/docs/content/docs/execution/meta.json +++ b/apps/docs/content/docs/execution/meta.json @@ -1,4 +1,4 @@ { "title": "Execution", - "pages": ["basics", "advanced"] + "pages": ["basics", "advanced", "api"] } diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts new file mode 100644 index 00000000000..78e9b1c1c58 --- /dev/null +++ b/apps/sim/app/api/v1/auth.ts @@ -0,0 +1,64 @@ +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { apiKey as apiKeyTable } from '@/db/schema' + +const logger = createLogger('V1Auth') + +export interface AuthResult { + authenticated: boolean + userId?: string + error?: string +} + +export async function authenticateApiKey(request: NextRequest): Promise { + const apiKey = request.headers.get('x-api-key') + + if (!apiKey) { + return { + authenticated: false, + error: 'API key required', + } + } + + try { + const [keyRecord] = await db + .select({ + userId: apiKeyTable.userId, + expiresAt: apiKeyTable.expiresAt, + }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKey)) + .limit(1) + + if (!keyRecord) { + logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) }) + return { + authenticated: false, + error: 'Invalid API key', + } + } + + if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) { + logger.warn('Expired API key attempted', { userId: keyRecord.userId }) + return { + authenticated: false, + error: 'API key expired', + } + } + + await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.key, apiKey)) + + return { + authenticated: true, + userId: keyRecord.userId, + } + } catch (error) { + logger.error('API key authentication error', { error }) + return { + authenticated: false, + error: 'Authentication failed', + } + } +} diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts new file mode 100644 index 00000000000..f2be5918ac8 --- /dev/null +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -0,0 +1,106 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { db } from '@/db' +import { permissions, workflow, workflowExecutionLogs } from '@/db/schema' + +const logger = createLogger('V1LogDetailsAPI') + +export const revalidate = 0 + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { id } = await params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) { + return NextResponse.json({ error: 'Log not found' }, { status: 404 }) + } + + const workflowSummary = { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt, + updatedAt: log.workflowUpdatedAt, + } + + const response = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt?.toISOString() || null, + totalDurationMs: log.totalDurationMs, + files: log.files || undefined, + workflow: workflowSummary, + executionData: log.executionData as any, + cost: log.cost as any, + createdAt: log.createdAt.toISOString(), + } + + // Get user's workflow execution limits and usage + const limits = await getUserLimits(userId) + + // Create response with limits information + const apiResponse = createApiResponse({ data: response }, limits, rateLimit) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: any) { + logger.error(`[${requestId}] Log details fetch error`, { error: error.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..6800a11551a --- /dev/null +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -0,0 +1,100 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { db } from '@/db' +import { + permissions, + workflow, + workflowExecutionLogs, + workflowExecutionSnapshots, +} from '@/db/schema' + +const logger = createLogger('V1ExecutionAPI') + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ executionId: string }> } +) { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { executionId } = await params + + logger.debug(`Fetching execution data for: ${executionId}`) + + const rows = await db + .select({ + log: workflowExecutionLogs, + workflow: workflow, + }) + .from(workflowExecutionLogs) + .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) { + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } + + const { log: workflowLog } = rows[0] + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) { + return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) + } + + const response = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt?.toISOString(), + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.cost || null, + }, + } + + logger.debug(`Successfully fetched execution data for: ${executionId}`) + logger.debug( + `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` + ) + + // Get user's workflow execution limits and usage + const limits = await getUserLimits(userId) + + // Create response with limits information + const apiResponse = createApiResponse( + { + ...response, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error) { + logger.error('Error fetching execution data:', error) + return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts new file mode 100644 index 00000000000..d2061d4a4e1 --- /dev/null +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -0,0 +1,110 @@ +import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { workflow, workflowExecutionLogs } from '@/db/schema' + +export interface LogFilters { + workspaceId: string + workflowIds?: string[] + folderIds?: string[] + triggers?: string[] + level?: 'info' | 'error' + startDate?: Date + endDate?: Date + executionId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + cursor?: { + startedAt: string + id: string + } + order?: 'desc' | 'asc' +} + +export function buildLogFilters(filters: LogFilters): SQL { + const conditions: SQL[] = [] + + // Required: workspace and permissions check + conditions.push(eq(workflow.workspaceId, filters.workspaceId)) + + // Cursor-based pagination + if (filters.cursor) { + const cursorDate = new Date(filters.cursor.startedAt) + if (filters.order === 'desc') { + conditions.push( + sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})` + ) + } else { + conditions.push( + sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})` + ) + } + } + + // Workflow IDs filter + if (filters.workflowIds && filters.workflowIds.length > 0) { + conditions.push(inArray(workflow.id, filters.workflowIds)) + } + + // Folder IDs filter + if (filters.folderIds && filters.folderIds.length > 0) { + conditions.push(inArray(workflow.folderId, filters.folderIds)) + } + + // Triggers filter + if (filters.triggers && filters.triggers.length > 0 && !filters.triggers.includes('all')) { + conditions.push(inArray(workflowExecutionLogs.trigger, filters.triggers)) + } + + // Level filter + if (filters.level) { + conditions.push(eq(workflowExecutionLogs.level, filters.level)) + } + + // Date range filters + if (filters.startDate) { + conditions.push(gte(workflowExecutionLogs.startedAt, filters.startDate)) + } + + if (filters.endDate) { + conditions.push(lte(workflowExecutionLogs.startedAt, filters.endDate)) + } + + // Search filter (execution ID) + if (filters.executionId) { + conditions.push(eq(workflowExecutionLogs.executionId, filters.executionId)) + } + + // Duration filters + if (filters.minDurationMs !== undefined) { + conditions.push(gte(workflowExecutionLogs.totalDurationMs, filters.minDurationMs)) + } + + if (filters.maxDurationMs !== undefined) { + conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs)) + } + + // Cost filters + if (filters.minCost !== undefined) { + conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric >= ${filters.minCost}`) + } + + if (filters.maxCost !== undefined) { + conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric <= ${filters.maxCost}`) + } + + // Model filter + if (filters.model) { + conditions.push(sql`${workflowExecutionLogs.cost}->>'models' ? ${filters.model}`) + } + + // Combine all conditions with AND + return conditions.length > 0 ? and(...conditions)! : sql`true` +} + +export function getOrderBy(order: 'desc' | 'asc' = 'desc') { + return order === 'desc' + ? desc(workflowExecutionLogs.startedAt) + : sql`${workflowExecutionLogs.startedAt} ASC` +} diff --git a/apps/sim/app/api/v1/logs/meta.ts b/apps/sim/app/api/v1/logs/meta.ts new file mode 100644 index 00000000000..df29f8ad124 --- /dev/null +++ b/apps/sim/app/api/v1/logs/meta.ts @@ -0,0 +1,78 @@ +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' +import { RateLimiter } from '@/services/queue' + +export interface UserLimits { + workflowExecutionRateLimit: { + sync: { + limit: number + remaining: number + resetAt: string + } + async: { + limit: number + remaining: number + resetAt: string + } + } + usage: { + currentPeriodCost: number + limit: number + plan: string + isExceeded: boolean + } +} + +export async function getUserLimits(userId: string): Promise { + const [userSubscription, usageCheck, effectiveCost, rateLimiter] = await Promise.all([ + getHighestPrioritySubscription(userId), + checkServerSideUsageLimits(userId), + getEffectiveCurrentPeriodCost(userId), + Promise.resolve(new RateLimiter()), + ]) + + const [syncStatus, asyncStatus] = await Promise.all([ + rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', false), + rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', true), + ]) + + return { + workflowExecutionRateLimit: { + sync: { + limit: syncStatus.limit, + remaining: syncStatus.remaining, + resetAt: syncStatus.resetAt.toISOString(), + }, + async: { + limit: asyncStatus.limit, + remaining: asyncStatus.remaining, + resetAt: asyncStatus.resetAt.toISOString(), + }, + }, + usage: { + currentPeriodCost: effectiveCost, + limit: usageCheck.limit, + plan: userSubscription?.plan || 'free', + isExceeded: usageCheck.isExceeded, + }, + } +} + +export function createApiResponse( + data: T, + limits: UserLimits, + apiRateLimit: { limit: number; remaining: number; resetAt: Date } +) { + return { + body: { + ...data, + limits, + }, + headers: { + 'X-RateLimit-Limit': apiRateLimit.limit.toString(), + 'X-RateLimit-Remaining': apiRateLimit.remaining.toString(), + 'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(), + }, + } +} diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts new file mode 100644 index 00000000000..aba568c580e --- /dev/null +++ b/apps/sim/app/api/v1/logs/route.ts @@ -0,0 +1,212 @@ +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { db } from '@/db' +import { permissions, workflow, workflowExecutionLogs } from '@/db/schema' + +const logger = createLogger('V1LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const QueryParamsSchema = z.object({ + workspaceId: z.string(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + level: z.enum(['info', 'error']).optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + executionId: z.string().optional(), + minDurationMs: z.coerce.number().optional(), + maxDurationMs: z.coerce.number().optional(), + minCost: z.coerce.number().optional(), + maxCost: z.coerce.number().optional(), + model: z.string().optional(), + details: z.enum(['basic', 'full']).optional().default('basic'), + includeTraceSpans: z.coerce.boolean().optional().default(false), + includeFinalOutput: z.coerce.boolean().optional().default(false), + limit: z.coerce.number().optional().default(100), + cursor: z.string().optional(), + order: z.enum(['desc', 'asc']).optional().default('desc'), +}) + +interface CursorData { + startedAt: string + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +function decodeCursor(cursor: string): CursorData | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) + } catch { + return null + } +} + +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { searchParams } = new URL(request.url) + const rawParams = Object.fromEntries(searchParams.entries()) + + const validationResult = QueryParamsSchema.safeParse(rawParams) + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid parameters', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const params = validationResult.data + + logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { + userId, + filters: { + workflowIds: params.workflowIds, + triggers: params.triggers, + level: params.level, + }, + }) + + // Build filter conditions + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const orderBy = getOrderBy(params.order) + + // Build and execute query + const baseQuery = db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + }) + .from(workflowExecutionLogs) + .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId), + eq(permissions.userId, userId) + ) + ) + + const logs = await baseQuery + .where(conditions) + .orderBy(orderBy) + .limit(params.limit + 1) + + const hasMore = logs.length > params.limit + const data = logs.slice(0, params.limit) + + let nextCursor: string | undefined + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ + startedAt: lastLog.startedAt.toISOString(), + id: lastLog.id, + }) + } + + const formattedLogs = data.map((log) => { + const result: any = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt?.toISOString() || null, + totalDurationMs: log.totalDurationMs, + cost: log.cost ? { total: (log.cost as any).total } : null, + files: log.files || null, + } + + if (params.details === 'full') { + result.workflow = { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + } + + if (log.cost) { + result.cost = log.cost + } + + if (log.executionData) { + const execData = log.executionData as any + if (params.includeFinalOutput && execData.finalOutput) { + result.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + result.traceSpans = execData.traceSpans + } + } + } + + return result + }) + + // Get user's workflow execution limits and usage + const limits = await getUserLimits(userId) + + // Create response with limits information + // The rateLimit object from checkRateLimit is for THIS API endpoint's rate limits + const response = createApiResponse( + { + data: formattedLogs, + nextCursor, + }, + limits, + rateLimit // This is the API endpoint rate limit, not workflow execution limits + ) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: any) { + logger.error(`[${requestId}] Logs fetch error`, { error: error.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts new file mode 100644 index 00000000000..61dff6aaf2c --- /dev/null +++ b/apps/sim/app/api/v1/middleware.ts @@ -0,0 +1,108 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { createLogger } from '@/lib/logs/console/logger' +import { RateLimiter } from '@/services/queue/RateLimiter' +import { authenticateApiKey } from './auth' + +const logger = createLogger('V1Middleware') +const rateLimiter = new RateLimiter() + +export interface RateLimitResult { + allowed: boolean + remaining: number + resetAt: Date + limit: number + userId?: string + error?: string +} + +export async function checkRateLimit( + request: NextRequest, + endpoint: 'logs' | 'logs-detail' = 'logs' +): Promise { + try { + const auth = await authenticateApiKey(request) + if (!auth.authenticated) { + return { + allowed: false, + remaining: 0, + limit: 10, // Default to free tier limit + resetAt: new Date(), + error: auth.error, + } + } + + const userId = auth.userId! + const subscription = await getHighestPrioritySubscription(userId) + + // Use api-endpoint trigger type for external API rate limiting + const result = await rateLimiter.checkRateLimitWithSubscription( + userId, + subscription, + 'api-endpoint', + false // Not relevant for api-endpoint trigger type + ) + + if (!result.allowed) { + logger.warn(`Rate limit exceeded for user ${userId}`, { + endpoint, + remaining: result.remaining, + resetAt: result.resetAt, + }) + } + + // Get the actual rate limit for this user's plan + const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription( + userId, + subscription, + 'api-endpoint', + false + ) + + return { + ...result, + limit: rateLimitStatus.limit, + userId, + } + } catch (error) { + logger.error('Rate limit check error', { error }) + return { + allowed: false, + remaining: 0, + limit: 10, + resetAt: new Date(Date.now() + 60000), + error: 'Rate limit check failed', + } + } +} + +export function createRateLimitResponse(result: RateLimitResult): NextResponse { + const headers = { + 'X-RateLimit-Limit': result.limit.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': result.resetAt.toISOString(), + } + + if (result.error) { + return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401, headers }) + } + + if (!result.allowed) { + return NextResponse.json( + { + error: 'Rate limit exceeded', + message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`, + retryAfter: result.resetAt.getTime(), + }, + { + status: 429, + headers: { + ...headers, + 'Retry-After': Math.ceil((result.resetAt.getTime() - Date.now()) / 1000).toString(), + }, + } + ) + } + + return NextResponse.json({ error: 'Bad request' }, { status: 400, headers }) +} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts new file mode 100644 index 00000000000..f08297cb26e --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts @@ -0,0 +1,221 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { encryptSecret } from '@/lib/utils' +import { db } from '@/db' +import { permissions, workflow, workflowLogWebhook } from '@/db/schema' + +const logger = createLogger('WorkflowLogWebhookUpdate') + +type WebhookUpdatePayload = Pick< + typeof workflowLogWebhook.$inferInsert, + | 'url' + | 'includeFinalOutput' + | 'includeTraceSpans' + | 'includeRateLimits' + | 'includeUsageData' + | 'levelFilter' + | 'triggerFilter' + | 'secret' + | 'updatedAt' +> + +const UpdateWebhookSchema = z.object({ + url: z.string().url('Invalid webhook URL'), + secret: z.string().optional(), + includeFinalOutput: z.boolean(), + includeTraceSpans: z.boolean(), + includeRateLimits: z.boolean(), + includeUsageData: z.boolean(), + levelFilter: z.array(z.enum(['info', 'error'])), + triggerFilter: z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])), +}) + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string; webhookId: string }> } +) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId, webhookId } = await params + const userId = session.user.id + + // Check if user has access to the workflow + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + // Check if webhook exists and belongs to this workflow + const existingWebhook = await db + .select() + .from(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) + ) + .limit(1) + + if (existingWebhook.length === 0) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + + const body = await request.json() + const validationResult = UpdateWebhookSchema.safeParse(body) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid request', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const data = validationResult.data + + // Check for duplicate URL (excluding current webhook) + const duplicateWebhook = await db + .select({ id: workflowLogWebhook.id }) + .from(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.workflowId, workflowId), eq(workflowLogWebhook.url, data.url)) + ) + .limit(1) + + if (duplicateWebhook.length > 0 && duplicateWebhook[0].id !== webhookId) { + return NextResponse.json( + { error: 'A webhook with this URL already exists for this workflow' }, + { status: 409 } + ) + } + + // Prepare update data + const updateData: WebhookUpdatePayload = { + url: data.url, + includeFinalOutput: data.includeFinalOutput, + includeTraceSpans: data.includeTraceSpans, + includeRateLimits: data.includeRateLimits, + includeUsageData: data.includeUsageData, + levelFilter: data.levelFilter, + triggerFilter: data.triggerFilter, + updatedAt: new Date(), + } + + // Only update secret if provided + if (data.secret) { + const { encrypted } = await encryptSecret(data.secret) + updateData.secret = encrypted + } + + const updatedWebhooks = await db + .update(workflowLogWebhook) + .set(updateData) + .where(eq(workflowLogWebhook.id, webhookId)) + .returning() + + if (updatedWebhooks.length === 0) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + + const updatedWebhook = updatedWebhooks[0] + + logger.info('Webhook updated', { + webhookId, + workflowId, + userId, + }) + + return NextResponse.json({ + data: { + id: updatedWebhook.id, + url: updatedWebhook.url, + includeFinalOutput: updatedWebhook.includeFinalOutput, + includeTraceSpans: updatedWebhook.includeTraceSpans, + includeRateLimits: updatedWebhook.includeRateLimits, + includeUsageData: updatedWebhook.includeUsageData, + levelFilter: updatedWebhook.levelFilter, + triggerFilter: updatedWebhook.triggerFilter, + active: updatedWebhook.active, + createdAt: updatedWebhook.createdAt.toISOString(), + updatedAt: updatedWebhook.updatedAt.toISOString(), + }, + }) + } catch (error) { + logger.error('Failed to update webhook', { error }) + return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; webhookId: string }> } +) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId, webhookId } = await params + const userId = session.user.id + + // Check if user has access to the workflow + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + // Delete the webhook (will cascade delete deliveries) + const deletedWebhook = await db + .delete(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) + ) + .returning() + + if (deletedWebhook.length === 0) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + + logger.info('Webhook deleted', { + webhookId, + workflowId, + userId, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to delete webhook', { error }) + return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/route.ts new file mode 100644 index 00000000000..b60bb05ca39 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/log-webhook/route.ts @@ -0,0 +1,248 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { encryptSecret } from '@/lib/utils' +import { db } from '@/db' +import { permissions, workflow, workflowLogWebhook } from '@/db/schema' + +const logger = createLogger('WorkflowLogWebhookAPI') + +const CreateWebhookSchema = z.object({ + url: z.string().url(), + secret: z.string().optional(), + includeFinalOutput: z.boolean().optional().default(false), + includeTraceSpans: z.boolean().optional().default(false), + includeRateLimits: z.boolean().optional().default(false), + includeUsageData: z.boolean().optional().default(false), + levelFilter: z + .array(z.enum(['info', 'error'])) + .optional() + .default(['info', 'error']), + triggerFilter: z + .array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) + .optional() + .default(['api', 'webhook', 'schedule', 'manual', 'chat']), + active: z.boolean().optional().default(true), +}) + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId } = await params + const userId = session.user.id + + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const webhooks = await db + .select({ + id: workflowLogWebhook.id, + url: workflowLogWebhook.url, + includeFinalOutput: workflowLogWebhook.includeFinalOutput, + includeTraceSpans: workflowLogWebhook.includeTraceSpans, + includeRateLimits: workflowLogWebhook.includeRateLimits, + includeUsageData: workflowLogWebhook.includeUsageData, + levelFilter: workflowLogWebhook.levelFilter, + triggerFilter: workflowLogWebhook.triggerFilter, + active: workflowLogWebhook.active, + createdAt: workflowLogWebhook.createdAt, + updatedAt: workflowLogWebhook.updatedAt, + }) + .from(workflowLogWebhook) + .where(eq(workflowLogWebhook.workflowId, workflowId)) + + return NextResponse.json({ data: webhooks }) + } catch (error) { + logger.error('Error fetching log webhooks', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId } = await params + const userId = session.user.id + + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const body = await request.json() + const validationResult = CreateWebhookSchema.safeParse(body) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid request', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const data = validationResult.data + + // Check for duplicate URL + const existingWebhook = await db + .select({ id: workflowLogWebhook.id }) + .from(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.workflowId, workflowId), eq(workflowLogWebhook.url, data.url)) + ) + .limit(1) + + if (existingWebhook.length > 0) { + return NextResponse.json( + { error: 'A webhook with this URL already exists for this workflow' }, + { status: 409 } + ) + } + + let encryptedSecret: string | null = null + + if (data.secret) { + const { encrypted } = await encryptSecret(data.secret) + encryptedSecret = encrypted + } + + const [webhook] = await db + .insert(workflowLogWebhook) + .values({ + id: uuidv4(), + workflowId, + url: data.url, + secret: encryptedSecret, + includeFinalOutput: data.includeFinalOutput, + includeTraceSpans: data.includeTraceSpans, + includeRateLimits: data.includeRateLimits, + includeUsageData: data.includeUsageData, + levelFilter: data.levelFilter, + triggerFilter: data.triggerFilter, + active: data.active, + }) + .returning() + + logger.info('Created log webhook', { + workflowId, + webhookId: webhook.id, + url: data.url, + }) + + return NextResponse.json({ + data: { + id: webhook.id, + url: webhook.url, + includeFinalOutput: webhook.includeFinalOutput, + includeTraceSpans: webhook.includeTraceSpans, + includeRateLimits: webhook.includeRateLimits, + includeUsageData: webhook.includeUsageData, + levelFilter: webhook.levelFilter, + triggerFilter: webhook.triggerFilter, + active: webhook.active, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + }, + }) + } catch (error) { + logger.error('Error creating log webhook', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId } = await params + const userId = session.user.id + const { searchParams } = new URL(request.url) + const webhookId = searchParams.get('webhookId') + + if (!webhookId) { + return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) + } + + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const deleted = await db + .delete(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) + ) + .returning({ id: workflowLogWebhook.id }) + + if (deleted.length === 0) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + + logger.info('Deleted log webhook', { + workflowId, + webhookId, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting log webhook', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts new file mode 100644 index 00000000000..1cb9e8ea4e1 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts @@ -0,0 +1,233 @@ +import { createHmac } from 'crypto' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { decryptSecret } from '@/lib/utils' +import { db } from '@/db' +import { permissions, workflow, workflowLogWebhook } from '@/db/schema' + +const logger = createLogger('WorkflowLogWebhookTestAPI') + +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + const hmac = createHmac('sha256', secret) + hmac.update(signatureBase) + return hmac.digest('hex') +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId } = await params + const userId = session.user.id + const { searchParams } = new URL(request.url) + const webhookId = searchParams.get('webhookId') + + if (!webhookId) { + return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) + } + + const hasAccess = await db + .select({ id: workflow.id }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (hasAccess.length === 0) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const [webhook] = await db + .select() + .from(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) + ) + .limit(1) + + if (!webhook) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + + const timestamp = Date.now() + const eventId = `evt_test_${uuidv4()}` + const executionId = `exec_test_${uuidv4()}` + const logId = `log_test_${uuidv4()}` + + const payload = { + id: eventId, + type: 'workflow.execution.completed', + timestamp, + data: { + workflowId, + executionId, + status: 'success', + level: 'info', + trigger: 'manual', + startedAt: new Date(timestamp - 5000).toISOString(), + endedAt: new Date(timestamp).toISOString(), + totalDurationMs: 5000, + cost: { + total: 0.00123, + tokens: { prompt: 100, completion: 50, total: 150 }, + models: { + 'gpt-4o': { + input: 0.001, + output: 0.00023, + total: 0.00123, + tokens: { prompt: 100, completion: 50, total: 150 }, + }, + }, + }, + files: null, + }, + links: { + log: `/v1/logs/${logId}`, + execution: `/v1/logs/executions/${executionId}`, + }, + } + + if (webhook.includeFinalOutput) { + ;(payload.data as any).finalOutput = { + message: 'This is a test webhook delivery', + test: true, + } + } + + if (webhook.includeTraceSpans) { + ;(payload.data as any).traceSpans = [ + { + id: 'span_test_1', + name: 'Test Block', + type: 'block', + status: 'success', + startTime: new Date(timestamp - 5000).toISOString(), + endTime: new Date(timestamp).toISOString(), + duration: 5000, + }, + ] + } + + if (webhook.includeRateLimits) { + ;(payload.data as any).rateLimits = { + workflowExecutionRateLimit: { + sync: { + limit: 60, + remaining: 45, + resetAt: new Date(timestamp + 60000).toISOString(), + }, + async: { + limit: 60, + remaining: 50, + resetAt: new Date(timestamp + 60000).toISOString(), + }, + }, + } + } + + if (webhook.includeUsageData) { + ;(payload.data as any).usage = { + currentPeriodCost: 2.45, + limit: 10, + plan: 'pro', + isExceeded: false, + } + } + + const body = JSON.stringify(payload) + const headers: Record = { + 'Content-Type': 'application/json', + 'sim-event': 'workflow.execution.completed', + 'sim-timestamp': timestamp.toString(), + 'sim-delivery-id': `delivery_test_${uuidv4()}`, + 'Idempotency-Key': `delivery_test_${uuidv4()}`, + } + + if (webhook.secret) { + const { decrypted } = await decryptSecret(webhook.secret) + const signature = generateSignature(decrypted, timestamp, body) + headers['sim-signature'] = `t=${timestamp},v1=${signature}` + } + + logger.info(`Sending test webhook to ${webhook.url}`, { workflowId, webhookId }) + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + const responseBody = await response.text().catch(() => '') + const truncatedBody = responseBody.slice(0, 500) + + const result = { + success: response.ok, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: truncatedBody, + timestamp: new Date().toISOString(), + } + + logger.info(`Test webhook completed`, { + workflowId, + webhookId, + status: response.status, + success: response.ok, + }) + + return NextResponse.json({ data: result }) + } catch (error: any) { + clearTimeout(timeoutId) + + if (error.name === 'AbortError') { + logger.error(`Test webhook timed out`, { workflowId, webhookId }) + return NextResponse.json({ + data: { + success: false, + error: 'Request timeout after 10 seconds', + timestamp: new Date().toISOString(), + }, + }) + } + + logger.error(`Test webhook failed`, { + workflowId, + webhookId, + error: error.message, + }) + + return NextResponse.json({ + data: { + success: false, + error: error.message, + timestamp: new Date().toISOString(), + }, + }) + } + } catch (error) { + logger.error('Error testing webhook', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/index.ts index f6ef340555e..ae7a07da354 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/index.ts @@ -3,3 +3,4 @@ export { DeploymentControls } from './deployment-controls/deployment-controls' export { ExportControls } from './export-controls/export-controls' export { TemplateModal } from './template-modal/template-modal' export { UserAvatarStack } from './user-avatar-stack/user-avatar-stack' +export { WebhookSettings } from './webhook-settings/webhook-settings' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/webhook-settings/webhook-settings.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/webhook-settings/webhook-settings.tsx new file mode 100644 index 00000000000..c0747a46b70 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/webhook-settings/webhook-settings.tsx @@ -0,0 +1,799 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + AlertCircle, + Bell, + Copy, + Edit2, + Eye, + EyeOff, + Plus, + RefreshCw, + Trash2, + Webhook, +} from 'lucide-react' +import { toast } from 'sonner' +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Label, + Separator, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui' +import { createLogger } from '@/lib/logs/console/logger' +import type { + LogLevel as StoreLogLevel, + TriggerType as StoreTriggerType, +} from '@/stores/logs/filters/types' + +const logger = createLogger('WebhookSettings') + +type NotificationLogLevel = Exclude +type NotificationTrigger = Exclude + +interface WebhookConfig { + id: string + url: string + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + levelFilter: NotificationLogLevel[] + triggerFilter: NotificationTrigger[] + active: boolean + createdAt: string + updatedAt: string +} + +interface WebhookSettingsProps { + workflowId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSettingsProps) { + const [webhooks, setWebhooks] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [isTesting, setIsTesting] = useState(null) + const [showSecret, setShowSecret] = useState(false) + const [editingWebhookId, setEditingWebhookId] = useState(null) + const [activeTab, setActiveTab] = useState('webhooks') + + interface EditableWebhookPayload { + url: string + secret: string + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + levelFilter: NotificationLogLevel[] + triggerFilter: NotificationTrigger[] + } + + const [newWebhook, setNewWebhook] = useState({ + url: '', + secret: '', + includeFinalOutput: false, + includeTraceSpans: false, + includeRateLimits: false, + includeUsageData: false, + levelFilter: ['info', 'error'], + triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], + }) + const [formError, setFormError] = useState(null) + + useEffect(() => { + if (open) { + loadWebhooks() + } + }, [open, workflowId]) + + const loadWebhooks = async () => { + try { + setIsLoading(true) + const response = await fetch(`/api/workflows/${workflowId}/log-webhook`) + if (response.ok) { + const data = await response.json() + setWebhooks(data.data || []) + } + } catch (error) { + logger.error('Failed to load webhooks', { error }) + toast.error('Failed to load webhook configurations') + } finally { + setIsLoading(false) + } + } + + const createWebhook = async () => { + setFormError(null) // Clear any previous errors + + if (!newWebhook.url) { + setFormError('Please enter a webhook URL') + return + } + + // Validate URL format + try { + const url = new URL(newWebhook.url) + if (!['http:', 'https:'].includes(url.protocol)) { + setFormError('URL must start with http:// or https://') + return + } + } catch { + setFormError('Please enter a valid URL (e.g., https://example.com/webhook)') + return + } + + // Validate filters are not empty + if (newWebhook.levelFilter.length === 0) { + setFormError('Please select at least one log level filter') + return + } + + if (newWebhook.triggerFilter.length === 0) { + setFormError('Please select at least one trigger filter') + return + } + + // Check for duplicate URL + const existingWebhook = webhooks.find((w) => w.url === newWebhook.url) + if (existingWebhook) { + setFormError('A webhook with this URL already exists') + return + } + + try { + setIsCreating(true) + setFormError(null) + const response = await fetch(`/api/workflows/${workflowId}/log-webhook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newWebhook), + }) + + if (response.ok) { + // Refresh the webhooks list to ensure consistency and avoid duplicates + await loadWebhooks() + setNewWebhook({ + url: '', + secret: '', + includeFinalOutput: false, + includeTraceSpans: false, + includeRateLimits: false, + includeUsageData: false, + levelFilter: ['info', 'error'], + triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], + }) + setFormError(null) + toast.success('Webhook created successfully') + } else { + const error = await response.json() + // Show detailed validation errors if available + if (error.details && Array.isArray(error.details)) { + const errorMessages = error.details.map((e: any) => e.message || e.path?.join('.')) + setFormError(`Validation failed: ${errorMessages.join(', ')}`) + } else { + setFormError(error.error || 'Failed to create webhook') + } + } + } catch (error) { + logger.error('Failed to create webhook', { error }) + setFormError('Failed to create webhook. Please try again.') + } finally { + setIsCreating(false) + } + } + + const deleteWebhook = async (webhookId: string) => { + try { + const response = await fetch( + `/api/workflows/${workflowId}/log-webhook?webhookId=${webhookId}`, + { + method: 'DELETE', + } + ) + + if (response.ok) { + // Refresh the webhooks list to ensure consistency + await loadWebhooks() + toast.success('Webhook deleted') + } else { + toast.error('Failed to delete webhook') + } + } catch (error) { + logger.error('Failed to delete webhook', { error }) + toast.error('Failed to delete webhook') + } + } + + const testWebhook = async (webhookId: string) => { + try { + setIsTesting(webhookId) + const response = await fetch( + `/api/workflows/${workflowId}/log-webhook/test?webhookId=${webhookId}`, + { + method: 'POST', + } + ) + + if (response.ok) { + const data = await response.json() + if (data.data.success) { + toast.success(`Test webhook sent successfully (${data.data.status})`) + } else { + toast.error(`Test webhook failed: ${data.data.error || data.data.statusText}`) + } + } else { + toast.error('Failed to send test webhook') + } + } catch (error) { + logger.error('Failed to test webhook', { error }) + toast.error('Failed to test webhook') + } finally { + setIsTesting(null) + } + } + + const copyWebhookId = (id: string) => { + navigator.clipboard.writeText(id) + toast.success('Webhook ID copied') + } + + const startEditWebhook = (webhook: WebhookConfig) => { + setEditingWebhookId(webhook.id) + setNewWebhook({ + url: webhook.url, + secret: '', // Don't expose the existing secret + includeFinalOutput: webhook.includeFinalOutput, + includeTraceSpans: webhook.includeTraceSpans, + includeRateLimits: webhook.includeRateLimits || false, + includeUsageData: webhook.includeUsageData || false, + levelFilter: webhook.levelFilter, + triggerFilter: webhook.triggerFilter, + }) + } + + const cancelEdit = () => { + setEditingWebhookId(null) + setFormError(null) + setNewWebhook({ + url: '', + secret: '', + includeFinalOutput: false, + includeTraceSpans: false, + includeRateLimits: false, + includeUsageData: false, + levelFilter: ['info', 'error'], + triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], + }) + } + + const updateWebhook = async () => { + if (!editingWebhookId) return + + // Validate URL format + try { + const url = new URL(newWebhook.url) + if (!['http:', 'https:'].includes(url.protocol)) { + toast.error('URL must start with http:// or https://') + return + } + } catch { + toast.error('Please enter a valid URL (e.g., https://example.com/webhook)') + return + } + + // Validate filters are not empty + if (newWebhook.levelFilter.length === 0) { + toast.error('Please select at least one log level filter') + return + } + + if (newWebhook.triggerFilter.length === 0) { + toast.error('Please select at least one trigger filter') + return + } + + // Check for duplicate URL (excluding current webhook) + const existingWebhook = webhooks.find( + (w) => w.url === newWebhook.url && w.id !== editingWebhookId + ) + if (existingWebhook) { + toast.error('A webhook with this URL already exists') + return + } + + try { + setIsCreating(true) + interface UpdateWebhookPayload { + url: string + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + levelFilter: NotificationLogLevel[] + triggerFilter: NotificationTrigger[] + secret?: string + active?: boolean + } + + let updateData: UpdateWebhookPayload = { + url: newWebhook.url, + includeFinalOutput: newWebhook.includeFinalOutput, + includeTraceSpans: newWebhook.includeTraceSpans, + includeRateLimits: newWebhook.includeRateLimits, + includeUsageData: newWebhook.includeUsageData, + levelFilter: newWebhook.levelFilter, + triggerFilter: newWebhook.triggerFilter, + } + + // Only include secret if it was changed + if (newWebhook.secret) { + updateData = { ...updateData, secret: newWebhook.secret } + } + + const response = await fetch(`/api/workflows/${workflowId}/log-webhook/${editingWebhookId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }) + + if (response.ok) { + await loadWebhooks() + cancelEdit() + toast.success('Webhook updated successfully') + } else { + const error = await response.json() + toast.error(error.error || 'Failed to update webhook') + } + } catch (error) { + logger.error('Failed to update webhook', { error }) + toast.error('Failed to update webhook') + } finally { + setIsCreating(false) + } + } + + return ( + + + + + + Webhook Notifications + + + Configure webhooks to receive notifications when workflow executions complete + + + + { + setActiveTab(value) + setFormError(null) // Clear any form errors when switching tabs + if (value === 'webhooks') { + loadWebhooks() + cancelEdit() // Cancel any ongoing edit + } + }} + > + + Active Webhooks + + {editingWebhookId ? 'Edit Webhook' : 'Create New'} + + + + +
+ {isLoading ? ( +
+ +
+ ) : webhooks.length === 0 ? ( +
+ + + +

+ No webhooks configured yet +

+

+ Create a webhook to receive execution notifications +

+
+
+
+ ) : ( +
+ {webhooks.map((webhook) => ( + + +
+
+ {webhook.url} + + + + {webhook.active ? 'Active' : 'Inactive'} + + + Created {new Date(webhook.createdAt).toLocaleDateString()} + + +
+
+ + + + + Copy Webhook ID + + + + + + + {isTesting === webhook.id ? 'Testing...' : 'Test Webhook'} + + + + + + + Edit Webhook + + + + + + Delete Webhook + +
+
+
+ +
+
+ Levels:{' '} + {webhook.levelFilter.join(', ')} +
+
+ Triggers:{' '} + {webhook.triggerFilter.join(', ')} +
+
+
+
+ + Include output +
+
+ + Include trace spans +
+
+ + Include usage data +
+
+ + Include rate limits +
+
+
+
+ ))} +
+ )} +
+
+ + +
+ {formError && ( +
+
+ +

{formError}

+
+
+ )} +
+
+ + { + setNewWebhook({ ...newWebhook, url: e.target.value }) + setFormError(null) // Clear error when user types + }} + className='mt-1.5' + /> +
+ +
+ +
+ { + setNewWebhook({ ...newWebhook, secret: e.target.value }) + setFormError(null) // Clear error when user types + }} + className='pr-10' + /> + +
+

+ Used to sign webhook payloads with HMAC-SHA256 +

+
+ + + +
+ +
+
+ { + if (checked) { + setNewWebhook({ + ...newWebhook, + levelFilter: [...newWebhook.levelFilter, 'info'], + }) + } else { + setNewWebhook({ + ...newWebhook, + levelFilter: newWebhook.levelFilter.filter((l) => l !== 'info'), + }) + } + }} + /> + +
+
+ { + if (checked) { + setNewWebhook({ + ...newWebhook, + levelFilter: [...newWebhook.levelFilter, 'error'], + }) + } else { + setNewWebhook({ + ...newWebhook, + levelFilter: newWebhook.levelFilter.filter((l) => l !== 'error'), + }) + } + }} + /> + +
+
+
+ +
+ +
+ {( + ['api', 'webhook', 'schedule', 'manual', 'chat'] as NotificationTrigger[] + ).map((trigger) => ( +
+ { + if (checked) { + setNewWebhook({ + ...newWebhook, + triggerFilter: [...newWebhook.triggerFilter, trigger], + }) + } else { + setNewWebhook({ + ...newWebhook, + triggerFilter: newWebhook.triggerFilter.filter( + (t) => t !== trigger + ), + }) + } + }} + /> + +
+ ))} +
+
+ + + +
+ +
+
+ + setNewWebhook({ ...newWebhook, includeFinalOutput: !!checked }) + } + /> + +
+
+ + setNewWebhook({ ...newWebhook, includeTraceSpans: !!checked }) + } + /> + +
+
+ + setNewWebhook({ ...newWebhook, includeRateLimits: !!checked }) + } + /> + +
+
+ + setNewWebhook({ ...newWebhook, includeUsageData: !!checked }) + } + /> + +
+
+

+ By default, only basic metadata and cost information is included +

+
+
+
+ +
+ {editingWebhookId && ( + + )} + +
+
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 292b29a44a6..77a24ae23fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -12,6 +12,7 @@ import { StepForward, Store, Trash2, + Webhook, WifiOff, X, } from 'lucide-react' @@ -31,6 +32,7 @@ import { TooltipTrigger, } from '@/components/ui' import { useSession } from '@/lib/auth-client' +import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -38,6 +40,7 @@ import { DeploymentControls, ExportControls, TemplateModal, + WebhookSettings, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { @@ -111,6 +114,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const [, forceUpdate] = useState({}) const [isExpanded, setIsExpanded] = useState(false) const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false) + const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false) const [isAutoLayouting, setIsAutoLayouting] = useState(false) // Delete workflow state - grouped for better organization @@ -705,6 +709,41 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { /> ) + /** + * Render webhook settings button + */ + const renderWebhookButton = () => { + // Only show webhook button if Trigger.dev is enabled + const isTriggerEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED')) + if (!isTriggerEnabled) return null + + const canEdit = userPermissions.canEdit + const isDisabled = !canEdit + + const getTooltipText = () => { + if (!canEdit) return 'Admin permission required to configure webhooks' + return 'Configure webhook notifications' + } + + return ( + + + + + {getTooltipText()} + + ) + } + /** * Render workflow duplicate button */ @@ -1215,6 +1254,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderDisconnectionNotice()} {renderToggleButton()} + {isExpanded && renderWebhookButton()} {isExpanded && } {isExpanded && renderAutoLayoutButton()} {isExpanded && renderPublishButton()} @@ -1232,6 +1272,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { workflowId={activeWorkflowId} /> )} + + {/* Webhook Settings */} + {activeWorkflowId && ( + + )}
) } diff --git a/apps/sim/background/logs-webhook-delivery.ts b/apps/sim/background/logs-webhook-delivery.ts new file mode 100644 index 00000000000..cf47c67b981 --- /dev/null +++ b/apps/sim/background/logs-webhook-delivery.ts @@ -0,0 +1,404 @@ +import { createHmac } from 'crypto' +import { task, wait } from '@trigger.dev/sdk' +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { createLogger } from '@/lib/logs/console/logger' +import type { WorkflowExecutionLog } from '@/lib/logs/types' +import { decryptSecret } from '@/lib/utils' +import { db } from '@/db' +import { + workflowLogWebhook, + workflowLogWebhookDelivery, + workflow as workflowTable, +} from '@/db/schema' + +const logger = createLogger('LogsWebhookDelivery') + +// Quick retry strategy: 5 attempts over ~15 minutes +// Most webhook failures are transient and resolve quickly +const MAX_ATTEMPTS = 5 +const RETRY_DELAYS = [ + 5 * 1000, // 5 seconds (1st retry) + 15 * 1000, // 15 seconds (2nd retry) + 60 * 1000, // 1 minute (3rd retry) + 3 * 60 * 1000, // 3 minutes (4th retry) + 10 * 60 * 1000, // 10 minutes (5th and final retry) +] + +// Add jitter to prevent thundering herd problem (up to 10% of delay) +function getRetryDelayWithJitter(baseDelay: number): number { + const jitter = Math.random() * 0.1 * baseDelay + return Math.floor(baseDelay + jitter) +} + +interface WebhookPayload { + id: string + type: 'workflow.execution.completed' + timestamp: number + data: { + workflowId: string + executionId: string + status: 'success' | 'error' + level: string + trigger: string + startedAt: string + endedAt: string + totalDurationMs: number + cost?: any + files?: any + finalOutput?: any + traceSpans?: any[] + rateLimits?: { + sync: { + limit: number + remaining: number + resetAt: string + } + async: { + limit: number + remaining: number + resetAt: string + } + } + usage?: { + currentPeriodCost: number + limit: number + plan: string + isExceeded: boolean + } + } + links: { + log: string + execution: string + } +} + +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + const hmac = createHmac('sha256', secret) + hmac.update(signatureBase) + return hmac.digest('hex') +} + +export const logsWebhookDelivery = task({ + id: 'logs-webhook-delivery', + retry: { + maxAttempts: 1, // We handle retries manually within the task + }, + run: async (params: { + deliveryId: string + subscriptionId: string + log: WorkflowExecutionLog + }) => { + const { deliveryId, subscriptionId, log } = params + + try { + const [subscription] = await db + .select() + .from(workflowLogWebhook) + .where(eq(workflowLogWebhook.id, subscriptionId)) + .limit(1) + + if (!subscription || !subscription.active) { + logger.warn(`Subscription ${subscriptionId} not found or inactive`) + await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'failed', + errorMessage: 'Subscription not found or inactive', + updatedAt: new Date(), + }) + .where(eq(workflowLogWebhookDelivery.id, deliveryId)) + return + } + + // Atomically claim this delivery row for processing and increment attempts + const claimed = await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'in_progress', + attempts: sql`${workflowLogWebhookDelivery.attempts} + 1`, + lastAttemptAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(workflowLogWebhookDelivery.id, deliveryId), + eq(workflowLogWebhookDelivery.status, 'pending'), + // Only claim if not scheduled in the future or schedule has arrived + or( + isNull(workflowLogWebhookDelivery.nextAttemptAt), + lte(workflowLogWebhookDelivery.nextAttemptAt, new Date()) + ) + ) + ) + .returning({ attempts: workflowLogWebhookDelivery.attempts }) + + if (claimed.length === 0) { + logger.info(`Delivery ${deliveryId} not claimable (already in progress or not due)`) + return + } + + const attempts = claimed[0].attempts + const timestamp = Date.now() + const eventId = `evt_${uuidv4()}` + + const payload: WebhookPayload = { + id: eventId, + type: 'workflow.execution.completed', + timestamp, + data: { + workflowId: log.workflowId, + executionId: log.executionId, + status: log.level === 'error' ? 'error' : 'success', + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt, + endedAt: log.endedAt || log.startedAt, + totalDurationMs: log.totalDurationMs, + cost: log.cost, + files: (log as any).files, + }, + links: { + log: `/v1/logs/${log.id}`, + execution: `/v1/logs/executions/${log.executionId}`, + }, + } + + if (subscription.includeFinalOutput && log.executionData) { + payload.data.finalOutput = (log.executionData as any).finalOutput + } + + if (subscription.includeTraceSpans && log.executionData) { + payload.data.traceSpans = (log.executionData as any).traceSpans + } + + // Fetch rate limits and usage data if requested + if ((subscription.includeRateLimits || subscription.includeUsageData) && log.executionData) { + const executionData = log.executionData as any + + const needsRateLimits = subscription.includeRateLimits && executionData.includeRateLimits + const needsUsage = subscription.includeUsageData && executionData.includeUsageData + if (needsRateLimits || needsUsage) { + const { getUserLimits } = await import('@/app/api/v1/logs/meta') + const workflow = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, log.workflowId)) + .limit(1) + + if (workflow.length > 0) { + try { + const limits = await getUserLimits(workflow[0].userId) + if (needsRateLimits) { + payload.data.rateLimits = limits.workflowExecutionRateLimit + } + if (needsUsage) { + payload.data.usage = limits.usage + } + } catch (error) { + logger.warn('Failed to fetch limits/usage for webhook', { error }) + } + } + } + } + + const body = JSON.stringify(payload) + const headers: Record = { + 'Content-Type': 'application/json', + 'sim-event': 'workflow.execution.completed', + 'sim-timestamp': timestamp.toString(), + 'sim-delivery-id': deliveryId, + 'Idempotency-Key': deliveryId, + } + + if (subscription.secret) { + const { decrypted } = await decryptSecret(subscription.secret) + const signature = generateSignature(decrypted, timestamp, body) + headers['sim-signature'] = `t=${timestamp},v1=${signature}` + } + + logger.info(`Attempting webhook delivery ${deliveryId} (attempt ${attempts})`, { + url: subscription.url, + executionId: log.executionId, + }) + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) + + try { + const response = await fetch(subscription.url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + const responseBody = await response.text().catch(() => '') + const truncatedBody = responseBody.slice(0, 1000) + + if (response.ok) { + await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'success', + attempts, + lastAttemptAt: new Date(), + responseStatus: response.status, + responseBody: truncatedBody, + errorMessage: null, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowLogWebhookDelivery.id, deliveryId), + eq(workflowLogWebhookDelivery.status, 'in_progress') + ) + ) + + logger.info(`Webhook delivery ${deliveryId} succeeded`, { + status: response.status, + executionId: log.executionId, + }) + + return { success: true } + } + + const isRetryable = response.status >= 500 || response.status === 429 + + if (!isRetryable || attempts >= MAX_ATTEMPTS) { + await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'failed', + attempts, + lastAttemptAt: new Date(), + responseStatus: response.status, + responseBody: truncatedBody, + errorMessage: `HTTP ${response.status}`, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowLogWebhookDelivery.id, deliveryId), + eq(workflowLogWebhookDelivery.status, 'in_progress') + ) + ) + + logger.warn(`Webhook delivery ${deliveryId} failed permanently`, { + status: response.status, + attempts, + executionId: log.executionId, + }) + + return { success: false } + } + + const baseDelay = RETRY_DELAYS[Math.min(attempts - 1, RETRY_DELAYS.length - 1)] + const delayWithJitter = getRetryDelayWithJitter(baseDelay) + const nextAttemptAt = new Date(Date.now() + delayWithJitter) + + await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'pending', + attempts, + lastAttemptAt: new Date(), + nextAttemptAt, + responseStatus: response.status, + responseBody: truncatedBody, + errorMessage: `HTTP ${response.status} - will retry`, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowLogWebhookDelivery.id, deliveryId), + eq(workflowLogWebhookDelivery.status, 'in_progress') + ) + ) + + // Schedule the next retry + await wait.for({ seconds: delayWithJitter / 1000 }) + + // Recursively call the task for retry + await logsWebhookDelivery.trigger({ + deliveryId, + subscriptionId, + log, + }) + + return { success: false, retrying: true } + } catch (error: any) { + clearTimeout(timeoutId) + + if (error.name === 'AbortError') { + logger.error(`Webhook delivery ${deliveryId} timed out`, { + executionId: log.executionId, + attempts, + }) + error.message = 'Request timeout after 30 seconds' + } + + const baseDelay = RETRY_DELAYS[Math.min(attempts - 1, RETRY_DELAYS.length - 1)] + const delayWithJitter = getRetryDelayWithJitter(baseDelay) + const nextAttemptAt = new Date(Date.now() + delayWithJitter) + + await db + .update(workflowLogWebhookDelivery) + .set({ + status: attempts >= MAX_ATTEMPTS ? 'failed' : 'pending', + attempts, + lastAttemptAt: new Date(), + nextAttemptAt: attempts >= MAX_ATTEMPTS ? null : nextAttemptAt, + errorMessage: error.message, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowLogWebhookDelivery.id, deliveryId), + eq(workflowLogWebhookDelivery.status, 'in_progress') + ) + ) + + if (attempts >= MAX_ATTEMPTS) { + logger.error(`Webhook delivery ${deliveryId} failed after ${attempts} attempts`, { + error: error.message, + executionId: log.executionId, + }) + return { success: false } + } + + // Schedule the next retry + await wait.for({ seconds: delayWithJitter / 1000 }) + + // Recursively call the task for retry + await logsWebhookDelivery.trigger({ + deliveryId, + subscriptionId, + log, + }) + + return { success: false, retrying: true } + } + } catch (error: any) { + logger.error(`Webhook delivery ${deliveryId} encountered unexpected error`, { + error: error.message, + stack: error.stack, + }) + + // Mark as failed for unexpected errors + await db + .update(workflowLogWebhookDelivery) + .set({ + status: 'failed', + errorMessage: `Unexpected error: ${error.message}`, + updatedAt: new Date(), + }) + .where(eq(workflowLogWebhookDelivery.id, deliveryId)) + + return { success: false, error: error.message } + } + }, +}) diff --git a/apps/sim/db/migrations/0086_breezy_sister_grimm.sql b/apps/sim/db/migrations/0086_breezy_sister_grimm.sql new file mode 100644 index 00000000000..e497a4a373c --- /dev/null +++ b/apps/sim/db/migrations/0086_breezy_sister_grimm.sql @@ -0,0 +1,43 @@ +CREATE TYPE "public"."webhook_delivery_status" AS ENUM('pending', 'in_progress', 'success', 'failed');--> statement-breakpoint +CREATE TABLE "workflow_log_webhook" ( + "id" text PRIMARY KEY NOT NULL, + "workflow_id" text NOT NULL, + "url" text NOT NULL, + "secret" text, + "include_final_output" boolean DEFAULT false NOT NULL, + "include_trace_spans" boolean DEFAULT false NOT NULL, + "include_rate_limits" boolean DEFAULT false NOT NULL, + "include_usage_data" boolean DEFAULT false NOT NULL, + "level_filter" text[] DEFAULT ARRAY['info', 'error']::text[] NOT NULL, + "trigger_filter" text[] DEFAULT ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[] NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_log_webhook_delivery" ( + "id" text PRIMARY KEY NOT NULL, + "subscription_id" text NOT NULL, + "workflow_id" text NOT NULL, + "execution_id" text NOT NULL, + "status" "webhook_delivery_status" DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_attempt_at" timestamp, + "next_attempt_at" timestamp, + "response_status" integer, + "response_body" text, + "error_message" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_rate_limits" ADD COLUMN "api_endpoint_requests" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_log_webhook" ADD CONSTRAINT "workflow_log_webhook_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_log_webhook_delivery" ADD CONSTRAINT "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."workflow_log_webhook"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_log_webhook_delivery" ADD CONSTRAINT "workflow_log_webhook_delivery_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_workflow_id_idx" ON "workflow_log_webhook" USING btree ("workflow_id");--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_active_idx" ON "workflow_log_webhook" USING btree ("active");--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_delivery_subscription_id_idx" ON "workflow_log_webhook_delivery" USING btree ("subscription_id");--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_delivery_execution_id_idx" ON "workflow_log_webhook_delivery" USING btree ("execution_id");--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_delivery_status_idx" ON "workflow_log_webhook_delivery" USING btree ("status");--> statement-breakpoint +CREATE INDEX "workflow_log_webhook_delivery_next_attempt_idx" ON "workflow_log_webhook_delivery" USING btree ("next_attempt_at"); \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0086_snapshot.json b/apps/sim/db/migrations/meta/0086_snapshot.json new file mode 100644 index 00000000000..c7c3c278c9f --- /dev/null +++ b/apps/sim/db/migrations/meta/0086_snapshot.json @@ -0,0 +1,6353 @@ +{ + "id": "ef439418-5e82-4e59-9013-8b116258d511", + "prevId": "563ccace-aa60-4c89-bc09-2a8102916d12", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_fill_env_vars": { + "name": "auto_fill_env_vars", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_pan": { + "name": "auto_pan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "console_expanded_by_default": { + "name": "console_expanded_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'FileText'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workflow_id_idx": { + "name": "templates_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_id_idx": { + "name": "templates_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_idx": { + "name": "templates_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_views_idx": { + "name": "templates_category_views_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_stars_idx": { + "name": "templates_category_stars_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_category_idx": { + "name": "templates_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "templates_user_id_user_id_fk": { + "name": "templates_user_id_user_id_fk", + "tableFrom": "templates", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_endpoint_requests": { + "name": "api_endpoint_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'10'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_api_key": { + "name": "pinned_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extent": { + "name": "extent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_parent_id_idx": { + "name": "workflow_blocks_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_parent_idx": { + "name": "workflow_blocks_workflow_parent_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_type_idx": { + "name": "workflow_blocks_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_source_block_idx": { + "name": "workflow_edges_source_block_idx", + "columns": [ + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_target_block_idx": { + "name": "workflow_edges_target_block_idx", + "columns": [ + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_idx": { + "name": "workflow_execution_logs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook": { + "name": "workflow_log_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_workflow_id_idx": { + "name": "workflow_log_webhook_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_active_idx": { + "name": "workflow_log_webhook_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook_delivery": { + "name": "workflow_log_webhook_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_delivery_subscription_id_idx": { + "name": "workflow_log_webhook_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_execution_id_idx": { + "name": "workflow_log_webhook_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_status_idx": { + "name": "workflow_log_webhook_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_next_attempt_idx": { + "name": "workflow_log_webhook_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk": { + "name": "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow_log_webhook", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_log_webhook_delivery_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 760c59bdada..11b02b022cf 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -596,6 +596,13 @@ "when": 1757348840739, "tag": "0085_daffy_blacklash", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1757441740591, + "tag": "0086_breezy_sister_grimm", + "breakpoints": true } ] } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index d4aa0fbd14b..0fee1444271 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -434,6 +434,75 @@ export const webhook = pgTable( } ) +export const workflowLogWebhook = pgTable( + 'workflow_log_webhook', + { + id: text('id').primaryKey(), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + url: text('url').notNull(), + secret: text('secret'), + includeFinalOutput: boolean('include_final_output').notNull().default(false), + includeTraceSpans: boolean('include_trace_spans').notNull().default(false), + includeRateLimits: boolean('include_rate_limits').notNull().default(false), + includeUsageData: boolean('include_usage_data').notNull().default(false), + levelFilter: text('level_filter') + .array() + .notNull() + .default(sql`ARRAY['info', 'error']::text[]`), + triggerFilter: text('trigger_filter') + .array() + .notNull() + .default(sql`ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]`), + active: boolean('active').notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + workflowIdIdx: index('workflow_log_webhook_workflow_id_idx').on(table.workflowId), + activeIdx: index('workflow_log_webhook_active_idx').on(table.active), + }) +) + +export const webhookDeliveryStatusEnum = pgEnum('webhook_delivery_status', [ + 'pending', + 'in_progress', + 'success', + 'failed', +]) + +export const workflowLogWebhookDelivery = pgTable( + 'workflow_log_webhook_delivery', + { + id: text('id').primaryKey(), + subscriptionId: text('subscription_id') + .notNull() + .references(() => workflowLogWebhook.id, { onDelete: 'cascade' }), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + executionId: text('execution_id').notNull(), + status: webhookDeliveryStatusEnum('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + lastAttemptAt: timestamp('last_attempt_at'), + nextAttemptAt: timestamp('next_attempt_at'), + responseStatus: integer('response_status'), + responseBody: text('response_body'), + errorMessage: text('error_message'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + subscriptionIdIdx: index('workflow_log_webhook_delivery_subscription_id_idx').on( + table.subscriptionId + ), + executionIdIdx: index('workflow_log_webhook_delivery_execution_id_idx').on(table.executionId), + statusIdx: index('workflow_log_webhook_delivery_status_idx').on(table.status), + nextAttemptIdx: index('workflow_log_webhook_delivery_next_attempt_idx').on(table.nextAttemptAt), + }) +) + export const apiKey = pgTable('api_key', { id: text('id').primaryKey(), userId: text('user_id') @@ -536,6 +605,7 @@ export const userRateLimits = pgTable('user_rate_limits', { referenceId: text('reference_id').primaryKey(), // Can be userId or organizationId for pooling syncApiRequests: integer('sync_api_requests').notNull().default(0), // Sync API requests counter asyncApiRequests: integer('async_api_requests').notNull().default(0), // Async API requests counter + apiEndpointRequests: integer('api_endpoint_requests').notNull().default(0), // External API endpoint requests counter windowStart: timestamp('window_start').notNull().defaultNow(), lastRequestAt: timestamp('last_request_at').notNull().defaultNow(), isRateLimited: boolean('is_rate_limited').notNull().default(false), diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts new file mode 100644 index 00000000000..0d0a5c1317c --- /dev/null +++ b/apps/sim/lib/logs/events.ts @@ -0,0 +1,101 @@ +import { and, eq } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { createLogger } from '@/lib/logs/console/logger' +import type { WorkflowExecutionLog } from '@/lib/logs/types' +import { logsWebhookDelivery } from '@/background/logs-webhook-delivery' +import { db } from '@/db' +import { workflowLogWebhook, workflowLogWebhookDelivery } from '@/db/schema' + +const logger = createLogger('LogsEventEmitter') + +export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise { + try { + const subscriptions = await db + .select() + .from(workflowLogWebhook) + .where( + and(eq(workflowLogWebhook.workflowId, log.workflowId), eq(workflowLogWebhook.active, true)) + ) + + if (subscriptions.length === 0) { + return + } + + logger.debug( + `Found ${subscriptions.length} active webhook subscriptions for workflow ${log.workflowId}` + ) + + for (const subscription of subscriptions) { + const levelMatches = subscription.levelFilter?.includes(log.level) ?? true + const triggerMatches = subscription.triggerFilter?.includes(log.trigger) ?? true + + if (!levelMatches || !triggerMatches) { + logger.debug(`Skipping subscription ${subscription.id} due to filter mismatch`, { + level: log.level, + trigger: log.trigger, + levelFilter: subscription.levelFilter, + triggerFilter: subscription.triggerFilter, + }) + continue + } + + const deliveryId = uuidv4() + + await db.insert(workflowLogWebhookDelivery).values({ + id: deliveryId, + subscriptionId: subscription.id, + workflowId: log.workflowId, + executionId: log.executionId, + status: 'pending', + attempts: 0, + nextAttemptAt: new Date(), + }) + + // Prepare the log data based on subscription settings + const webhookLog = { + ...log, + executionData: {}, + } + + // Only include executionData fields that are requested + if (log.executionData) { + const data = log.executionData as any + const webhookData: any = {} + + if (subscription.includeFinalOutput && data.finalOutput) { + webhookData.finalOutput = data.finalOutput + } + + if (subscription.includeTraceSpans && data.traceSpans) { + webhookData.traceSpans = data.traceSpans + } + + // For rate limits and usage, we'll need to fetch them in the webhook delivery + // since they're user-specific and may change + if (subscription.includeRateLimits) { + webhookData.includeRateLimits = true + } + + if (subscription.includeUsageData) { + webhookData.includeUsageData = true + } + + webhookLog.executionData = webhookData + } + + await logsWebhookDelivery.trigger({ + deliveryId, + subscriptionId: subscription.id, + log: webhookLog, + }) + + logger.info(`Enqueued webhook delivery ${deliveryId} for subscription ${subscription.id}`) + } + } catch (error) { + logger.error('Failed to emit workflow execution completed event', { + error, + workflowId: log.workflowId, + executionId: log.executionId, + }) + } +} diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index cf2a1789b82..c092e497e60 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -4,6 +4,7 @@ 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 { emitWorkflowExecutionCompleted } from '@/lib/logs/events' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import type { BlockOutputData, @@ -306,7 +307,7 @@ export class ExecutionLogger implements IExecutionLoggerService { logger.debug(`Completed workflow execution ${executionId}`) - return { + const completedLog: WorkflowExecutionLog = { id: updatedLog.id, workflowId: updatedLog.workflowId, executionId: updatedLog.executionId, @@ -320,6 +321,15 @@ export class ExecutionLogger implements IExecutionLoggerService { cost: updatedLog.cost as any, createdAt: updatedLog.createdAt.toISOString(), } + + emitWorkflowExecutionCompleted(completedLog).catch((error) => { + logger.error('Failed to emit workflow execution completed event', { + error, + executionId, + }) + }) + + return completedLog } async getWorkflowExecution(executionId: string): Promise { diff --git a/apps/sim/services/queue/RateLimiter.ts b/apps/sim/services/queue/RateLimiter.ts index cfb679e6023..88d8c1580ff 100644 --- a/apps/sim/services/queue/RateLimiter.ts +++ b/apps/sim/services/queue/RateLimiter.ts @@ -7,6 +7,7 @@ import { MANUAL_EXECUTION_LIMIT, RATE_LIMIT_WINDOW_MS, RATE_LIMITS, + type RateLimitCounterType, type SubscriptionPlan, type TriggerType, } from '@/services/queue/types' @@ -43,6 +44,50 @@ export class RateLimiter { return userId } + /** + * Determine which counter type to use based on trigger type and async flag + */ + private getCounterType(triggerType: TriggerType, isAsync: boolean): RateLimitCounterType { + if (triggerType === 'api-endpoint') { + return 'api-endpoint' + } + return isAsync ? 'async' : 'sync' + } + + /** + * Get the rate limit for a specific counter type + */ + private getRateLimitForCounter( + config: (typeof RATE_LIMITS)[SubscriptionPlan], + counterType: RateLimitCounterType + ): number { + switch (counterType) { + case 'api-endpoint': + return config.apiEndpointRequestsPerMinute + case 'async': + return config.asyncApiExecutionsPerMinute + case 'sync': + return config.syncApiExecutionsPerMinute + } + } + + /** + * Get the current count from a rate limit record for a specific counter type + */ + private getCountFromRecord( + record: { syncApiRequests: number; asyncApiRequests: number; apiEndpointRequests: number }, + counterType: RateLimitCounterType + ): number { + switch (counterType) { + case 'api-endpoint': + return record.apiEndpointRequests + case 'async': + return record.asyncApiRequests + case 'sync': + return record.syncApiRequests + } + } + /** * Check if user can execute a workflow with organization-aware rate limiting * Manual executions bypass rate limiting entirely @@ -64,11 +109,10 @@ export class RateLimiter { const subscriptionPlan = (subscription?.plan || 'free') as SubscriptionPlan const rateLimitKey = this.getRateLimitKey(userId, subscription) - const limit = RATE_LIMITS[subscriptionPlan] - const execLimit = isAsync - ? limit.asyncApiExecutionsPerMinute - : limit.syncApiExecutionsPerMinute + + const counterType = this.getCounterType(triggerType, isAsync) + const execLimit = this.getRateLimitForCounter(limit, counterType) const now = new Date() const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS) @@ -86,8 +130,9 @@ export class RateLimiter { .insert(userRateLimits) .values({ referenceId: rateLimitKey, - syncApiRequests: isAsync ? 0 : 1, - asyncApiRequests: isAsync ? 1 : 0, + syncApiRequests: counterType === 'sync' ? 1 : 0, + asyncApiRequests: counterType === 'async' ? 1 : 0, + apiEndpointRequests: counterType === 'api-endpoint' ? 1 : 0, windowStart: now, lastRequestAt: now, isRateLimited: false, @@ -96,8 +141,9 @@ export class RateLimiter { target: userRateLimits.referenceId, set: { // Only reset if window is still expired (avoid race condition) - syncApiRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${isAsync ? 0 : 1} ELSE ${userRateLimits.syncApiRequests} + ${isAsync ? 0 : 1} END`, - asyncApiRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${isAsync ? 1 : 0} ELSE ${userRateLimits.asyncApiRequests} + ${isAsync ? 1 : 0} END`, + syncApiRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${counterType === 'sync' ? 1 : 0} ELSE ${userRateLimits.syncApiRequests} + ${counterType === 'sync' ? 1 : 0} END`, + asyncApiRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${counterType === 'async' ? 1 : 0} ELSE ${userRateLimits.asyncApiRequests} + ${counterType === 'async' ? 1 : 0} END`, + apiEndpointRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${counterType === 'api-endpoint' ? 1 : 0} ELSE ${userRateLimits.apiEndpointRequests} + ${counterType === 'api-endpoint' ? 1 : 0} END`, windowStart: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${now.toISOString()} ELSE ${userRateLimits.windowStart} END`, lastRequestAt: now, isRateLimited: false, @@ -107,13 +153,12 @@ export class RateLimiter { .returning({ syncApiRequests: userRateLimits.syncApiRequests, asyncApiRequests: userRateLimits.asyncApiRequests, + apiEndpointRequests: userRateLimits.apiEndpointRequests, windowStart: userRateLimits.windowStart, }) const insertedRecord = result[0] - const actualCount = isAsync - ? insertedRecord.asyncApiRequests - : insertedRecord.syncApiRequests + const actualCount = this.getCountFromRecord(insertedRecord, counterType) // Check if we exceeded the limit if (actualCount > execLimit) { @@ -160,21 +205,22 @@ export class RateLimiter { const updateResult = await db .update(userRateLimits) .set({ - ...(isAsync - ? { asyncApiRequests: sql`${userRateLimits.asyncApiRequests} + 1` } - : { syncApiRequests: sql`${userRateLimits.syncApiRequests} + 1` }), + ...(counterType === 'api-endpoint' + ? { apiEndpointRequests: sql`${userRateLimits.apiEndpointRequests} + 1` } + : counterType === 'async' + ? { asyncApiRequests: sql`${userRateLimits.asyncApiRequests} + 1` } + : { syncApiRequests: sql`${userRateLimits.syncApiRequests} + 1` }), lastRequestAt: now, }) .where(eq(userRateLimits.referenceId, rateLimitKey)) .returning({ asyncApiRequests: userRateLimits.asyncApiRequests, syncApiRequests: userRateLimits.syncApiRequests, + apiEndpointRequests: userRateLimits.apiEndpointRequests, }) const updatedRecord = updateResult[0] - const actualNewRequests = isAsync - ? updatedRecord.asyncApiRequests - : updatedRecord.syncApiRequests + const actualNewRequests = this.getCountFromRecord(updatedRecord, counterType) // Check if we exceeded the limit AFTER the atomic increment if (actualNewRequests > execLimit) { @@ -264,11 +310,11 @@ export class RateLimiter { const subscriptionPlan = (subscription?.plan || 'free') as SubscriptionPlan const rateLimitKey = this.getRateLimitKey(userId, subscription) - const limit = RATE_LIMITS[subscriptionPlan] - const execLimit = isAsync - ? limit.asyncApiExecutionsPerMinute - : limit.syncApiExecutionsPerMinute + + const counterType = this.getCounterType(triggerType, isAsync) + const execLimit = this.getRateLimitForCounter(limit, counterType) + const now = new Date() const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS) @@ -287,7 +333,7 @@ export class RateLimiter { } } - const used = isAsync ? rateLimitRecord.asyncApiRequests : rateLimitRecord.syncApiRequests + const used = this.getCountFromRecord(rateLimitRecord, counterType) return { used, limit: execLimit, diff --git a/apps/sim/services/queue/types.ts b/apps/sim/services/queue/types.ts index dff5ed78772..01eb8a524ab 100644 --- a/apps/sim/services/queue/types.ts +++ b/apps/sim/services/queue/types.ts @@ -6,15 +6,19 @@ import type { userRateLimits } from '@/db/schema' export type UserRateLimit = InferSelectModel // Trigger types for rate limiting -export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' +export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint' + +// Rate limit counter types - which counter to increment in the database +export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint' // Subscription plan types export type SubscriptionPlan = 'free' | 'pro' | 'team' | 'enterprise' -// Rate limit configuration (applies to all non-manual trigger types: api, webhook, schedule, chat) +// Rate limit configuration (applies to all non-manual trigger types: api, webhook, schedule, chat, api-endpoint) export interface RateLimitConfig { syncApiExecutionsPerMinute: number asyncApiExecutionsPerMinute: number + apiEndpointRequestsPerMinute: number // For external API endpoints like /api/v1/logs } // Rate limit window duration in milliseconds @@ -27,18 +31,22 @@ export const RATE_LIMITS: Record = { free: { syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10, asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50, + apiEndpointRequestsPerMinute: 10, }, pro: { syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25, asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200, + apiEndpointRequestsPerMinute: 30, }, team: { syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75, asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500, + apiEndpointRequestsPerMinute: 60, }, enterprise: { syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150, asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000, + apiEndpointRequestsPerMinute: 120, }, } From a5c224e4b076525ec66adcbc8e68fd4219332335 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 9 Sep 2025 12:50:25 -0700 Subject: [PATCH 06/15] fix(workflow-block): remove process specific circular dependency check (#1293) * fix(workflow-block): remove process specific circular dep check * remove comments --- .../handlers/workflow/workflow-handler.test.ts | 18 ------------------ .../handlers/workflow/workflow-handler.ts | 15 --------------- 2 files changed, 33 deletions(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index d6df0cd77d6..3e817b5beed 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -50,10 +50,6 @@ describe('WorkflowBlockHandler', () => { // Reset all mocks vi.clearAllMocks() - // Clear the static execution stack - - ;(WorkflowBlockHandler as any).executionStack.clear() - // Setup default fetch mock mockFetch.mockResolvedValue({ ok: true, @@ -102,20 +98,6 @@ describe('WorkflowBlockHandler', () => { ) }) - it('should detect and prevent cyclic dependencies', async () => { - const inputs = { workflowId: 'child-workflow-id' } - - // Simulate a cycle by adding the execution to the stack - - ;(WorkflowBlockHandler as any).executionStack.add( - 'parent-workflow-id_sub_child-workflow-id_workflow-block-1' - ) - - await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow( - 'Error in child workflow "child-workflow-id": Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id_workflow-block-1' - ) - }) - it('should enforce maximum depth limit', async () => { const inputs = { workflowId: 'child-workflow-id' } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 9555f8da319..fc3603417d4 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -21,7 +21,6 @@ const MAX_WORKFLOW_DEPTH = 10 */ export class WorkflowBlockHandler implements BlockHandler { private serializer = new Serializer() - private static executionStack = new Set() canHandle(block: SerializedBlock): boolean { return block.metadata?.id === BlockType.WORKFLOW @@ -47,15 +46,6 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Maximum workflow nesting depth of ${MAX_WORKFLOW_DEPTH} exceeded`) } - // Check for cycles - include block ID to differentiate parallel executions - const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}` - if (WorkflowBlockHandler.executionStack.has(executionId)) { - throw new Error(`Cyclic workflow dependency detected: ${executionId}`) - } - - // Add current execution to stack - WorkflowBlockHandler.executionStack.add(executionId) - // Load the child workflow from API const childWorkflow = await this.loadChildWorkflow(workflowId) @@ -102,9 +92,6 @@ export class WorkflowBlockHandler implements BlockHandler { const result = await subExecutor.execute(workflowId) const duration = performance.now() - startTime - // Remove current execution from stack after completion - WorkflowBlockHandler.executionStack.delete(executionId) - logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`) const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context) @@ -131,8 +118,6 @@ export class WorkflowBlockHandler implements BlockHandler { } catch (error: any) { logger.error(`Error executing child workflow ${workflowId}:`, error) - const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}` - WorkflowBlockHandler.executionStack.delete(executionId) const { workflows } = useWorkflowRegistry.getState() const workflowMetadata = workflows[workflowId] const childWorkflowName = workflowMetadata?.name || workflowId From ae670a7819799df590d0c4f502fb4469e7cd2574 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 9 Sep 2025 12:58:21 -0700 Subject: [PATCH 07/15] fix(start-input): restore tag dropdown in input-format component (#1294) * update infra and remove railway * fix(input-format): restore tag dropdown in input-format component * Revert "update infra and remove railway" This reverts commit 7ade5fb2ef74cab0f314a6812ae231b861355136. * style improvements --- .../components/starter/input-format.tsx | 161 +++++++++++++++--- 1 file changed, 133 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx index 7753e82953b..001235e5c0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -17,6 +18,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' @@ -72,7 +74,12 @@ export function FieldFormat({ const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const [dragHighlight, setDragHighlight] = useState>({}) const valueInputRefs = useRef>({}) + const overlayRefs = useRef>({}) const [localValues, setLocalValues] = useState>({}) + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const [activeFieldId, setActiveFieldId] = useState(null) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue @@ -106,18 +113,19 @@ export function FieldFormat({ setStoreValue((fields || []).filter((field: Field) => field.id !== id)) } - // Validate field name for API safety const validateFieldName = (name: string): string => { - // Remove only truly problematic characters for JSON/API usage - // Allow most characters but remove control characters, quotes, and backslashes return name.replace(/[\x00-\x1F"\\]/g, '').trim() } - const handleValueInputChange = (fieldId: string, newValue: string) => { + const handleValueInputChange = (fieldId: string, newValue: string, caretPosition?: number) => { setLocalValues((prev) => ({ ...prev, [fieldId]: newValue })) - } - // Value normalization: keep it simple for string types + const position = typeof caretPosition === 'number' ? caretPosition : newValue.length + setCursorPosition(position) + setActiveFieldId(fieldId) + const trigger = checkTagTrigger(newValue, position) + setShowTags(trigger.show) + } const handleValueInputBlur = (field: Field) => { if (isPreview || disabled) return @@ -148,6 +156,47 @@ export function FieldFormat({ setDragHighlight((prev) => ({ ...prev, [fieldId]: false })) const input = valueInputRefs.current[fieldId] input?.focus() + + if (input) { + const currentValue = + localValues[fieldId] ?? (fields.find((f) => f.id === fieldId)?.value as string) ?? '' + const dropPosition = (input as any).selectionStart ?? currentValue.length + const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}` + setLocalValues((prev) => ({ ...prev, [fieldId]: newValue })) + setActiveFieldId(fieldId) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data?.connectionData?.sourceBlockId) { + setActiveSourceBlockId(data.connectionData.sourceBlockId) + } + } catch {} + + setTimeout(() => { + const el = valueInputRefs.current[fieldId] + if (el && typeof (el as any).selectionStart === 'number') { + ;(el as any).selectionStart = dropPosition + 1 + ;(el as any).selectionEnd = dropPosition + 1 + } + }, 0) + } + } + + const handleValueScroll = (fieldId: string, e: React.UIEvent) => { + const overlay = overlayRefs.current[fieldId] + if (overlay) { + overlay.scrollLeft = e.currentTarget.scrollLeft + } + } + + const handleValuePaste = (fieldId: string) => { + setTimeout(() => { + const input = valueInputRefs.current[fieldId] as HTMLInputElement | undefined + const overlay = overlayRefs.current[fieldId] + if (input && overlay) overlay.scrollLeft = input.scrollLeft + }, 0) } // Update handlers @@ -351,7 +400,13 @@ export function FieldFormat({ }} name='value' value={localValues[field.id] ?? (field.value as string) ?? ''} - onChange={(e) => handleValueInputChange(field.id, e.target.value)} + onChange={(e) => + handleValueInputChange( + field.id, + e.target.value, + e.target.selectionStart ?? undefined + ) + } onBlur={() => handleValueInputBlur(field)} placeholder={ field.type === 'object' ? '{\n "key": "value"\n}' : '[\n 1, 2, 3\n]' @@ -364,30 +419,80 @@ export function FieldFormat({ config?.connectionDroppable !== false && 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500' )} - /> - ) : ( - { - if (el) valueInputRefs.current[field.id] = el - }} - name='value' - value={localValues[field.id] ?? field.value ?? ''} - onChange={(e) => handleValueInputChange(field.id, e.target.value)} - onBlur={() => handleValueInputBlur(field)} - onDragOver={(e) => handleDragOver(e, field.id)} - onDragLeave={(e) => handleDragLeave(e, field.id)} onDrop={(e) => handleDrop(e, field.id)} - placeholder={valuePlaceholder} - disabled={isPreview || disabled} - className={cn( - 'h-9 placeholder:text-muted-foreground/50', - dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2', - isConnecting && - config?.connectionDroppable !== false && - 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500' - )} + onDragOver={(e) => + handleDragOver(e as unknown as React.DragEvent, field.id) + } + onDragLeave={(e) => + handleDragLeave(e as unknown as React.DragEvent, field.id) + } /> + ) : ( + <> + { + if (el) valueInputRefs.current[field.id] = el + }} + name='value' + value={localValues[field.id] ?? field.value ?? ''} + onChange={(e) => + handleValueInputChange( + field.id, + e.target.value, + e.target.selectionStart ?? undefined + ) + } + onBlur={() => handleValueInputBlur(field)} + onDragOver={(e) => handleDragOver(e, field.id)} + onDragLeave={(e) => handleDragLeave(e, field.id)} + onDrop={(e) => handleDrop(e, field.id)} + onScroll={(e) => handleValueScroll(field.id, e)} + onPaste={() => handleValuePaste(field.id)} + placeholder={valuePlaceholder} + disabled={isPreview || disabled} + className={cn( + 'allow-scroll h-9 w-full overflow-auto text-transparent caret-foreground placeholder:text-muted-foreground/50', + dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2', + isConnecting && + config?.connectionDroppable !== false && + 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500' + )} + style={{ overflowX: 'auto' }} + /> +
{ + if (el) overlayRefs.current[field.id] = el + }} + className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-3 text-sm' + style={{ overflowX: 'auto' }} + > +
+ {formatDisplayText( + (localValues[field.id] ?? field.value ?? '')?.toString(), + true + )} +
+
+ )} + {/* Tag dropdown for response value field */} + { + setLocalValues((prev) => ({ ...prev, [field.id]: newValue })) + if (!isPreview && !disabled) updateField(field.id, 'value', newValue) + setShowTags(false) + setActiveSourceBlockId(null) + }} + blockId={blockId} + activeSourceBlockId={activeSourceBlockId} + inputValue={localValues[field.id] ?? (field.value as string) ?? ''} + cursorPosition={cursorPosition} + onClose={() => setShowTags(false)} + /> )} From 8f7b11f0894b095f87a23271697f5e737883887d Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 9 Sep 2025 16:09:31 -0700 Subject: [PATCH 08/15] feat(account): added user profile pictures in settings (#1297) * update infra and remove railway * feat(account): add profile pictures * Revert "update infra and remove railway" This reverts commit e3f0c494568b19f0bd0fa84934efe3a85b1e371d. * ack PR comments, use brandConfig logo URL as default --- apps/sim/app/api/files/presigned/route.ts | 49 ++++- apps/sim/app/api/users/me/profile/route.ts | 14 +- .../components/account/account.tsx | 148 ++++++++++---- .../hooks/use-profile-picture-upload.ts | 193 ++++++++++++++++++ apps/sim/lib/env.ts | 2 + apps/sim/lib/uploads/setup.ts | 12 ++ 6 files changed, 371 insertions(+), 47 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/hooks/use-profile-picture-upload.ts diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 98f58315c64..bcf88b51295 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -12,10 +12,12 @@ import { BLOB_CONFIG, BLOB_COPILOT_CONFIG, BLOB_KB_CONFIG, + BLOB_PROFILE_PICTURES_CONFIG, S3_CHAT_CONFIG, S3_CONFIG, S3_COPILOT_CONFIG, S3_KB_CONFIG, + S3_PROFILE_PICTURES_CONFIG, } from '@/lib/uploads/setup' import { validateFileType } from '@/lib/uploads/validation' import { createErrorResponse, createOptionsResponse } from '@/app/api/files/utils' @@ -30,7 +32,7 @@ interface PresignedUrlRequest { chatId?: string } -type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot' +type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot' | 'profile-pictures' class PresignedUrlError extends Error { constructor( @@ -96,7 +98,9 @@ export async function POST(request: NextRequest) { ? 'chat' : uploadTypeParam === 'copilot' ? 'copilot' - : 'general' + : uploadTypeParam === 'profile-pictures' + ? 'profile-pictures' + : 'general' if (uploadType === 'knowledge-base') { const fileValidationError = validateFileType(fileName, contentType) @@ -121,6 +125,21 @@ export async function POST(request: NextRequest) { } } + // Validate profile picture requirements + if (uploadType === 'profile-pictures') { + if (!sessionUserId?.trim()) { + throw new ValidationError( + 'Authenticated user session is required for profile picture uploads' + ) + } + // Only allow image uploads for profile pictures + if (!isImageFileType(contentType)) { + throw new ValidationError( + 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads' + ) + } + } + if (!isUsingCloudStorage()) { throw new StorageConfigError( 'Direct uploads are only available when cloud storage is enabled' @@ -185,7 +204,9 @@ async function handleS3PresignedUrl( ? S3_CHAT_CONFIG : uploadType === 'copilot' ? S3_COPILOT_CONFIG - : S3_CONFIG + : uploadType === 'profile-pictures' + ? S3_PROFILE_PICTURES_CONFIG + : S3_CONFIG if (!config.bucket || !config.region) { throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`) @@ -200,6 +221,8 @@ async function handleS3PresignedUrl( prefix = 'chat/' } else if (uploadType === 'copilot') { prefix = `${userId}/` + } else if (uploadType === 'profile-pictures') { + prefix = `${userId}/` } const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` @@ -219,6 +242,9 @@ async function handleS3PresignedUrl( } else if (uploadType === 'copilot') { metadata.purpose = 'copilot' metadata.userId = userId || '' + } else if (uploadType === 'profile-pictures') { + metadata.purpose = 'profile-pictures' + metadata.userId = userId || '' } const command = new PutObjectCommand({ @@ -239,9 +265,9 @@ async function handleS3PresignedUrl( ) } - // For chat images and knowledge base files, use direct URLs since they need to be accessible by external services + // For chat images, knowledge base files, and profile pictures, use direct URLs since they need to be accessible by external services const finalPath = - uploadType === 'chat' || uploadType === 'knowledge-base' + uploadType === 'chat' || uploadType === 'knowledge-base' || uploadType === 'profile-pictures' ? `https://${config.bucket}.s3.${config.region}.amazonaws.com/${uniqueKey}` : `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}` @@ -285,7 +311,9 @@ async function handleBlobPresignedUrl( ? BLOB_CHAT_CONFIG : uploadType === 'copilot' ? BLOB_COPILOT_CONFIG - : BLOB_CONFIG + : uploadType === 'profile-pictures' + ? BLOB_PROFILE_PICTURES_CONFIG + : BLOB_CONFIG if ( !config.accountName || @@ -304,6 +332,8 @@ async function handleBlobPresignedUrl( prefix = 'chat/' } else if (uploadType === 'copilot') { prefix = `${userId}/` + } else if (uploadType === 'profile-pictures') { + prefix = `${userId}/` } const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` @@ -339,10 +369,10 @@ async function handleBlobPresignedUrl( const presignedUrl = `${blockBlobClient.url}?${sasToken}` - // For chat images, use direct Blob URLs since they need to be permanently accessible + // For chat images and profile pictures, use direct Blob URLs since they need to be permanently accessible // For other files, use serve path for access control const finalPath = - uploadType === 'chat' + uploadType === 'chat' || uploadType === 'profile-pictures' ? blockBlobClient.url : `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}` @@ -362,6 +392,9 @@ async function handleBlobPresignedUrl( } else if (uploadType === 'copilot') { uploadHeaders['x-ms-meta-purpose'] = 'copilot' uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '') + } else if (uploadType === 'profile-pictures') { + uploadHeaders['x-ms-meta-purpose'] = 'profile-pictures' + uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '') } return NextResponse.json({ diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts index e34e01d979b..9d1e554f18d 100644 --- a/apps/sim/app/api/users/me/profile/route.ts +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -12,11 +12,18 @@ const logger = createLogger('UpdateUserProfileAPI') const UpdateProfileSchema = z .object({ name: z.string().min(1, 'Name is required').optional(), + image: z.string().url('Invalid image URL').optional(), }) - .refine((data) => data.name !== undefined, { - message: 'Name field must be provided', + .refine((data) => data.name !== undefined || data.image !== undefined, { + message: 'At least one field (name or image) must be provided', }) +interface UpdateData { + updatedAt: Date + name?: string + image?: string | null +} + export const dynamic = 'force-dynamic' export async function PATCH(request: NextRequest) { @@ -36,8 +43,9 @@ export async function PATCH(request: NextRequest) { const validatedData = UpdateProfileSchema.parse(body) // Build update object - const updateData: any = { updatedAt: new Date() } + const updateData: UpdateData = { updatedAt: new Date() } if (validatedData.name !== undefined) updateData.name = validatedData.name + if (validatedData.image !== undefined) updateData.image = validatedData.image // Update user profile const [updatedUser] = await db diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx index 77b37c4f5ab..99749d6ecfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx @@ -1,14 +1,18 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Camera } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/navigation' import { AgentIcon } from '@/components/icons' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { signOut, useSession } from '@/lib/auth-client' +import { useBrandConfig } from '@/lib/branding/branding' import { createLogger } from '@/lib/logs/console/logger' +import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/hooks/use-profile-picture-upload' import { clearUserData } from '@/stores' const logger = createLogger('Account') @@ -17,33 +21,81 @@ interface AccountProps { onOpenChange: (open: boolean) => void } -export function Account({ onOpenChange }: AccountProps) { +export function Account(_props: AccountProps) { const router = useRouter() + const brandConfig = useBrandConfig() - // Get session data using the client hook - const { data: session, isPending, error } = useSession() + const { data: session, isPending } = useSession() - // Form states const [name, setName] = useState('') const [email, setEmail] = useState('') const [userImage, setUserImage] = useState(null) - // Loading states const [isLoadingProfile, setIsLoadingProfile] = useState(false) const [isUpdatingName, setIsUpdatingName] = useState(false) - // Edit states const [isEditingName, setIsEditingName] = useState(false) const inputRef = useRef(null) - // Reset password state const [isResettingPassword, setIsResettingPassword] = useState(false) const [resetPasswordMessage, setResetPasswordMessage] = useState<{ type: 'success' | 'error' text: string } | null>(null) - // Fetch user profile on component mount + const [uploadError, setUploadError] = useState(null) + + const { + previewUrl: profilePictureUrl, + fileInputRef: profilePictureInputRef, + handleThumbnailClick: handleProfilePictureClick, + handleFileChange: handleProfilePictureChange, + isUploading: isUploadingProfilePicture, + } = useProfilePictureUpload({ + currentImage: userImage, + onUpload: async (url) => { + if (url) { + try { + await updateUserImage(url) + setUploadError(null) + } catch (error) { + setUploadError('Failed to update profile picture') + } + } else { + try { + await updateUserImage(null) + setUploadError(null) + } catch (error) { + setUploadError('Failed to remove profile picture') + } + } + }, + onError: (error) => { + setUploadError(error) + setTimeout(() => setUploadError(null), 5000) + }, + }) + + const updateUserImage = async (imageUrl: string | null) => { + try { + const response = await fetch('/api/users/me/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageUrl }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update profile picture') + } + + setUserImage(imageUrl) + } catch (error) { + logger.error('Error updating profile image:', error) + throw error + } + } + useEffect(() => { const fetchProfile = async () => { if (!session?.user) return @@ -62,7 +114,6 @@ export function Account({ onOpenChange }: AccountProps) { setUserImage(data.user.image) } catch (error) { logger.error('Error fetching profile:', error) - // Fallback to session data if (session?.user) { setName(session.user.name || '') setEmail(session.user.email || '') @@ -76,7 +127,6 @@ export function Account({ onOpenChange }: AccountProps) { fetchProfile() }, [session]) - // Focus input when entering edit mode useEffect(() => { if (isEditingName && inputRef.current) { inputRef.current.focus() @@ -172,7 +222,6 @@ export function Account({ onOpenChange }: AccountProps) { text: 'email sent', }) - // Clear success message after 5 seconds setTimeout(() => { setResetPasswordMessage(null) }, 5000) @@ -183,7 +232,6 @@ export function Account({ onOpenChange }: AccountProps) { text: 'error', }) - // Clear error message after 5 seconds setTimeout(() => { setResetPasswordMessage(null) }, 5000) @@ -242,31 +290,59 @@ export function Account({ onOpenChange }: AccountProps) { <> {/* User Info Section */}
- {/* User Avatar */} -
- {userImage ? ( - {name - ) : ( - - )} + {/* Profile Picture Upload */} +
+
+ {(() => { + const imageUrl = profilePictureUrl || userImage || brandConfig.logoUrl + return imageUrl ? ( + {name + ) : ( + + ) + })()} + + {/* Upload overlay */} +
+ {isUploadingProfilePicture ? ( +
+ ) : ( + + )} +
+
+ + {/* Hidden file input */} +
{/* User Details */} -
-

{name}

+
+

{name}

{email}

+ {uploadError &&

{uploadError}

}
{/* Name Field */}
-
- {isWorkflowSelected(workflow.id) && } + {isWorkflowSelected(workflow.id) && ( + + )} ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx index 0d6aa59a184..a228259a1b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx @@ -119,7 +119,7 @@ export function AuthSelector({ {isExistingChat && !password && (
-
+
Password set
Current password is securely stored diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx index 6b83941b43f..f6067209291 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx @@ -170,7 +170,7 @@ export function DeployForm({ type='button' variant='ghost' size='sm' - className='h-7 gap-1 px-2 text-primary text-xs' + className='h-7 gap-1 px-2 text-muted-foreground text-xs' onClick={() => setIsCreatingKey(true)} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx index a7c5014cc18..543e2e49aa3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx @@ -156,7 +156,7 @@ export function ExampleCommand({ onClick={() => setMode('sync')} className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${ mode === 'sync' - ? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground' + ? 'border-primary bg-primary text-muted-foreground hover:border-primary hover:bg-primary hover:text-muted-foreground' : '' }`} > @@ -168,7 +168,7 @@ export function ExampleCommand({ onClick={() => setMode('async')} className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${ mode === 'async' - ? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground' + ? 'border-primary bg-primary text-muted-foreground hover:border-primary hover:bg-primary hover:text-muted-foreground' : '' }`} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx index 60bfaa5ef5b..3a1a237675b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx @@ -446,7 +446,7 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP className={cn( 'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted', field.value === icon.value && - 'bg-primary text-primary-foreground' + 'bg-primary text-muted-foreground' )} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx index 906324cd341..810f01fbab8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx @@ -391,7 +391,7 @@ export function OutputSelect({
{output.path} {selectedOutputs.includes(output.id) && ( - + )} ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index ca5e3df6b4d..b9d24798614 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -319,7 +319,7 @@ export function CredentialSelector({ {credentials.length === 0 && ( -
+
{getProviderIcon(provider)} Connect {getProviderName(provider)} account
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx index 940a55461b4..d145d880e06 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -541,7 +541,7 @@ export function ConfluenceFileSelector({ {credentials.length === 0 && ( -
+
Connect Confluence account
@@ -585,7 +585,7 @@ export function ConfluenceFileSelector({ href={selectedFile.webViewLink} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > Open in Confluence diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index 9648f5ac579..918bf24cd64 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -498,7 +498,7 @@ export function GoogleDrivePicker({ href={selectedFile.webViewLink} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-muted-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > Open in Drive @@ -509,7 +509,7 @@ export function GoogleDrivePicker({ href={`https://drive.google.com/file/d/${selectedFile.id}/view`} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-muted-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > Open in Drive diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index 1dc84555bf6..ae749184568 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -583,7 +583,7 @@ export function JiraIssueSelector({ {credentials.length === 0 && ( -
+
Connect Jira account
@@ -627,7 +627,7 @@ export function JiraIssueSelector({ href={selectedIssue.webViewLink} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > Open in Jira diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index 1123a9960eb..220ccfb23d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -931,7 +931,7 @@ export function MicrosoftFileSelector({ {credentials.length === 0 && ( -
+
{getProviderIcon(provider)} Connect {getProviderName(provider)} account
@@ -975,7 +975,7 @@ export function MicrosoftFileSelector({ href={selectedFile.webViewLink} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > @@ -992,7 +992,7 @@ export function MicrosoftFileSelector({ href={`https://graph.microsoft.com/v1.0/me/drive/items/${selectedFile.id}`} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index 170d8360ccc..9a34c984d39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -828,7 +828,7 @@ export function TeamsMessageSelector({ {credentials.length === 0 && ( -
+
Connect Microsoft Teams account
@@ -870,7 +870,7 @@ export function TeamsMessageSelector({ href={selectedMessage.webViewLink} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-1 text-primary text-xs hover:underline' + className='flex items-center gap-1 text-foreground text-xs hover:underline' onClick={(e) => e.stopPropagation()} > Open in Microsoft Teams diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx index 47771323b7e..4cbf3a01b43 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx @@ -405,7 +405,7 @@ export function WealthboxFileSelector({ {credentials.length === 0 && ( -
+
Connect Wealthbox account
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx index 18e9109292f..15a15768b7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx @@ -517,7 +517,7 @@ export function FileUpload({ data-testid='file-input-element' /> -
+
{/* File list with consistent spacing */} {(hasFiles || isUploading) && (
@@ -533,7 +533,11 @@ export function FileUpload({ <> {uploadingFiles.map(renderUploadingItem)}
- +
{uploadProgress < 100 ? 'Uploading...' : 'Upload complete!'}
@@ -545,7 +549,7 @@ export function FileUpload({ {/* Action buttons */} {(hasFiles || isUploading) && ( -
+