Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 55 additions & 95 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { generateApiKey } from '@/lib/api-key/service'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
Expand DownExpand Up@@ -64,26 +63,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)

if (userApiKey.length === 0) {
try {
const newApiKeyVal = generateApiKey()
const keyName = 'Default API Key'
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
workspaceId: null,
name: keyName,
key: newApiKeyVal,
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
})
keyInfo = { name: keyName, type: 'personal' }
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
} else {
if (userApiKey.length > 0) {
keyInfo = { name: userApiKey[0].name, type: userApiKey[0].type as 'personal' | 'workspace' }
}
}
Expand DownExpand Up@@ -190,34 +170,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)

const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)

if (userApiKey.length === 0) {
try {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId,
workspaceId: null,
name: 'Default API Key',
key: newApiKey,
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
})
logger.info(`[${requestId}] Generated new API key for user: ${userId}`)
} catch (keyError) {
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
}

let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
let matchedKey: {
id: string
Expand All@@ -226,13 +178,50 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
type: 'personal' | 'workspace'
} | null = null

if (providedApiKey) {
let isValidKey = false
// Use provided API key, or fall back to existing pinned API key for redeployment
const apiKeyToUse = providedApiKey || workflowData!.pinnedApiKeyId

if (!apiKeyToUse) {
return NextResponse.json(
{ error: 'API key is required. Please create or select an API key before deploying.' },
{ status: 400 }
)
}

let isValidKey = false

const currentUserId = session?.user?.id

if (currentUserId) {
const [personalKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(
and(
eq(apiKey.id, apiKeyToUse),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)

const currentUserId = session?.user?.id
if (personalKey) {
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
matchedKey = { ...personalKey, type: 'personal' }
isValidKey = true
keyInfo = { name: personalKey.name, type: 'personal' }
}
}
}

if (currentUserId) {
const [personalKey] = await db
if (!isValidKey) {
if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
Expand All@@ -242,55 +231,26 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
eq(apiKey.id, apiKeyToUse),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)

if (personalKey) {
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
matchedKey = { ...personalKey, type: 'personal' }
if (workspaceKey) {
if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) {
matchedKey = { ...workspaceKey, type: 'workspace' }
isValidKey = true
keyInfo = { name: personalKey.name, type: 'personal' }
}
}
}

if (!isValidKey) {
if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)

if (workspaceKey) {
if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) {
matchedKey = { ...workspaceKey, type: 'workspace' }
isValidKey = true
keyInfo = { name: workspaceKey.name, type: 'workspace' }
}
keyInfo = { name: workspaceKey.name, type: 'workspace' }
}
}
}
}

if (!isValidKey) {
logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`)
return createErrorResponse('Invalid API key provided', 400)
}
if (!isValidKey) {
logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`)
return createErrorResponse('Invalid API key provided', 400)
}

// Attribution: this route is UI-only; require session user as actor
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,6 +361,8 @@ export function DeployModal({
await fetchVersions()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -432,6 +432,17 @@ export const WorkflowBlock = memo(
}
}, [id, blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, debounce])

// Subscribe to this block's subblock values to track changes for conditional rendering
const blockSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return {}
return state.workflowValues[activeWorkflowId]?.[id] || {}
},
[activeWorkflowId, id]
)
)

// Memoized SubBlock layout management - only recalculate when dependencies change
const subBlockRows = useMemo(() => {
const rows: SubBlockConfig[][] = []
Expand All@@ -450,8 +461,7 @@ export const WorkflowBlock = memo(
} else {
// In normal mode, use merged state
const blocks = useWorkflowStore.getState().blocks
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId || undefined
const mergedState = mergeSubblockState(blocks, activeWorkflowId, id)[id]
const mergedState = mergeSubblockState(blocks, activeWorkflowId || undefined, id)[id]
stateToUse = mergedState?.subBlocks || {}
}

Expand DownExpand Up@@ -552,6 +562,8 @@ export const WorkflowBlock = memo(
data.subBlockValues,
currentWorkflow.isDiffMode,
currentBlock,
blockSubBlockValues,
activeWorkflowId,
])

// Name editing handlers
Expand Down
86 changes: 86 additions & 0 deletions apps/sim/background/webhook-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { decryptSecret } from '@/lib/utils'
import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils'
import {
loadDeployedWorkflowState,
Expand All@@ -20,9 +21,66 @@ import { Executor } from '@/executor'
import type { ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
import { getTrigger } from '@/triggers'

const logger = createLogger('TriggerWebhookExecution')

/**
* Process trigger outputs based on their schema definitions
* Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage
*/
async function processTriggerFileOutputs(
input: any,
triggerOutputs: Record<string, any>,
context: {
workspaceId: string
workflowId: string
executionId: string
requestId: string
},
path = ''
): Promise<any> {
if (!input || typeof input !== 'object') {
return input
}

const processed: any = Array.isArray(input) ? [] : {}

for (const [key, value] of Object.entries(input)) {
const currentPath = path ? `${path}.${key}` : key
const outputDef = triggerOutputs[key]
const val: any = value

// If this field is marked as file or file[], process it
if (outputDef?.type === 'file[]' && Array.isArray(val)) {
try {
processed[key] = await WebhookAttachmentProcessor.processAttachments(val as any, context)
} catch (error) {
processed[key] = []
}
} else if (outputDef?.type === 'file' && val) {
try {
const [processedFile] = await WebhookAttachmentProcessor.processAttachments(
[val as any],
context
)
processed[key] = processedFile
} catch (error) {
logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error)
processed[key] = val
}
} else if (outputDef && typeof outputDef === 'object' && !outputDef.type) {
// Nested object in schema - recurse with the nested schema
processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath)
} else {
// Not a file output - keep as is
processed[key] = val
}
}

return processed
}

export type WebhookExecutionPayload = {
webhookId: string
workflowId: string
Expand DownExpand Up@@ -250,6 +308,7 @@ async function executeWebhookJobInternal(
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
workflowInput: airtableInput,
})

return {
Expand DownExpand Up@@ -312,6 +371,32 @@ async function executeWebhookJobInternal(
}
}

// Process trigger file outputs based on schema
if (input && payload.blockId && blocks[payload.blockId]) {
try {
const triggerBlock = blocks[payload.blockId]
const triggerId = triggerBlock?.subBlocks?.triggerId?.value

if (triggerId && typeof triggerId === 'string') {
const triggerConfig = getTrigger(triggerId)

if (triggerConfig?.outputs) {
logger.debug(`[${requestId}] Processing trigger ${triggerId} file outputs`)
const processedInput = await processTriggerFileOutputs(input, triggerConfig.outputs, {
workspaceId: workspaceId || '',
workflowId: payload.workflowId,
executionId,
requestId,
})
Object.assign(input, processedInput)
}
}
} catch (error) {
logger.error(`[${requestId}] Error processing trigger file outputs:`, error)
// Continue without processing attachments rather than failing execution
}
}

// Create executor and execute
const executor = new Executor({
workflow: serializedWorkflow,
Expand DownExpand Up@@ -367,6 +452,7 @@ async function executeWebhookJobInternal(
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
workflowInput: input,
})

return {
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/blocks/blocks/outlook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,13 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
placeholder: 'Number of emails to retrieve (default: 1, max: 10)',
condition: { field: 'operation', value: 'read_outlook' },
},
{
id: 'includeAttachments',
title: 'Include Attachments',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'read_outlook' },
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
Expand DownExpand Up@@ -231,6 +238,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
folder: { type: 'string', description: 'Email folder' },
manualFolder: { type: 'string', description: 'Manual folder name' },
maxResults: { type: 'number', description: 'Maximum emails' },
includeAttachments: { type: 'boolean', description: 'Include email attachments' },
},
outputs: {
// Common outputs
Expand All@@ -255,6 +263,10 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
receivedDateTime: { type: 'string', description: 'Email received timestamp' },
sentDateTime: { type: 'string', description: 'Email sent timestamp' },
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
attachments: {
type: 'json',
description: 'Email attachments (if includeAttachments is enabled)',
},
isRead: { type: 'boolean', description: 'Whether email is read' },
importance: { type: 'string', description: 'Email importance level' },
// Trigger outputs
Expand Down
Loading