improvement(webhooks) (#256)

* improvement(webhooks): styling alignment and route refactor and de-deuplication detection

* improvement(ui/ux): familiarized styling and loading between schedule and webhook

* improvement(webhooks): simplified instructions and aligned styling on webhook configs

* improvement(webhooks): upgraded tests
This commit is contained in:
Emir Karabeg
2025-04-13 00:17:05 -07:00
committed by GitHub
parent cd15f8cba0
commit 172dd0da51
28 changed files with 1908 additions and 1410 deletions
@@ -305,12 +305,12 @@ describe('Webhook Trigger API Route', () => {
// Call the handler
const response = await POST(req, { params })
// Check response - should be 404
expect(response.status).toBe(404)
// Check response - expect 200 due to the fast timeout response
expect(response.status).toBe(200)
// Parse the response body
const text = await response.text()
expect(text).toBe('Webhook not found')
expect(text).toMatch(/received|processing|another instance/i) // Response might be "Request received" or "Request is being processed by another instance"
})
/**
@@ -474,8 +474,8 @@ describe('Webhook Trigger API Route', () => {
// Call the handler
const response = await POST(req, { params })
// Verify response exists and check status code
// Verify response exists and check status code - expect 200 due to timeout response
expect(response).toBeDefined()
expect(response.status).toBe(500)
expect(response.status).toBe(200)
})
})
File diff suppressed because it is too large Load Diff
-66
View File
@@ -1,66 +0,0 @@
/**
* Validates a Slack webhook request signature using HMAC SHA-256
* @param signingSecret - Slack signing secret for validation
* @param signature - X-Slack-Signature header value
* @param timestamp - X-Slack-Request-Timestamp header value
* @param body - Raw request body string
* @returns Whether the signature is valid
*/
export async function validateSlackSignature(
signingSecret: string,
signature: string,
timestamp: string,
body: string
): Promise<boolean> {
try {
// Basic validation first
if (!signingSecret || !signature || !timestamp || !body) {
return false
}
// Check if the timestamp is too old (> 5 minutes)
const currentTime = Math.floor(Date.now() / 1000)
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false
}
// Compute the signature
const encoder = new TextEncoder()
const baseString = `v0:${timestamp}:${body}`
// Create the HMAC with the signing secret
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(signingSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(baseString))
// Convert the signature to hex
const signatureHex = Array.from(new Uint8Array(signatureBytes))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
// Prepare the expected signature format
const computedSignature = `v0=${signatureHex}`
// Constant-time comparison to prevent timing attacks
if (computedSignature.length !== signature.length) {
return false
}
let result = 0
for (let i = 0; i < computedSignature.length; i++) {
result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i)
}
return result === 0
} catch (error) {
console.error('Error validating Slack signature:', error)
return false
}
}
@@ -18,6 +18,7 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { Notice } from '@/components/ui/notice'
import {
Select,
SelectContent,
@@ -447,12 +448,9 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
const renderPublishForm = () => (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="mb-4 rounded-md bg-amber-50 p-3 border border-amber-200">
<p className="text-sm text-amber-800">
<span className="font-medium text-amber-800">Security:</span> API keys and environment
variables will be automatically removed before publishing.
</p>
</div>
<Notice variant="warning" title="Security">
API keys and environment variables will be automatically removed before publishing.
</Notice>
<FormField
control={form.control}
@@ -151,12 +151,12 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
{expandAllJson ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
<span className="text-xs">Collapse all</span>
<span className="text-xs">Collapse</span>
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
<span className="text-xs">Expand all</span>
<span className="text-xs">Expand</span>
</>
)}
</span>
@@ -96,7 +96,11 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
</SelectTrigger>
<SelectContent className="max-h-48">
{evaluatedOptions.map((option) => (
<SelectItem key={getOptionValue(option)} value={getOptionValue(option)}>
<SelectItem
key={getOptionValue(option)}
value={getOptionValue(option)}
className="text-base"
>
{getOptionLabel(option)}
</SelectItem>
))}
@@ -4,6 +4,7 @@ import { Calendar, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console-logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { formatDateTime } from '@/lib/utils'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -11,7 +12,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { ScheduleModal } from './components/schedule-modal'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
const logger = createLogger('ScheduleConfig')
@@ -38,10 +38,6 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
const workflowId = params.id as string
// Get workflow state from store
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const triggerUpdate = useWorkflowStore((state) => state.triggerUpdate)
const setScheduleStatus = useWorkflowStore((state) => state.setScheduleStatus)
// Get the schedule type from the block state
@@ -225,7 +221,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
setTimeout(() => {
logger.debug('Refreshing schedule information after save')
setRefreshCounter((prev) => prev + 1)
// Make a separate API call to ensure we get the latest schedule info
checkSchedule()
}, 500)
@@ -302,11 +298,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
<div className="w-full" onClick={(e) => e.stopPropagation()}>
{error && <div className="text-sm text-red-500 dark:text-red-400 mb-2">{error}</div>}
{isLoading ? (
<div className="flex items-center justify-center py-2">
<div className="h-5 w-5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
</div>
) : isScheduleActive ? (
{isScheduleActive ? (
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between px-3 py-2 rounded border border-border bg-background">
<div className="flex items-center gap-2 flex-1">
@@ -336,7 +328,11 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
>
<Calendar className="h-4 w-4 mr-2" />
{isLoading ? (
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
) : (
<Calendar className="h-4 w-4 mr-2" />
)}
Configure Schedule
</Button>
)}
@@ -7,6 +7,7 @@ import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay as WebhookTestResult } from '../ui/test-result'
import { WebhookConfigField } from '../ui/webhook-config-field'
interface AirtableConfigProps {
baseId: string
@@ -21,6 +22,7 @@ interface AirtableConfigProps {
copyToClipboard: (text: string, type: string) => void
testWebhook?: () => void // Optional test function
webhookId?: string // Webhook ID to enable testing
webhookUrl: string // Added webhook URL
}
export function AirtableConfig({
@@ -34,12 +36,25 @@ export function AirtableConfig({
testResult,
copied,
copyToClipboard,
testWebhook, // We might need this later for instructions
webhookId, // We might need this later for instructions
testWebhook,
webhookId,
webhookUrl,
}: AirtableConfigProps) {
return (
<div className="space-y-4">
<ConfigSection title="Airtable Configuration">
<WebhookConfigField
id="webhook-url"
label="Webhook URL"
value={webhookUrl}
description="This is the URL that will receive webhook requests"
isLoading={isLoadingToken}
copied={copied}
copyType="url"
copyToClipboard={copyToClipboard}
readOnly={true}
/>
<ConfigField
id="airtable-base-id"
label="Base ID *"
@@ -126,17 +126,6 @@ export function GithubConfig({
<li>Ensure "Active" is checked and click "Add webhook".</li>
</ol>
</InstructionsSection>
<Alert>
<ShieldCheck className="h-4 w-4" />
<AlertTitle>Security Recommendations</AlertTitle>
<AlertDescription>
<ul className="list-disc list-outside pl-4 space-y-1 mt-1">
<li>Always use a strong, unique secret token to validate GitHub requests.</li>
<li>Keep SSL verification enabled unless absolutely necessary.</li>
</ul>
</AlertDescription>
</Alert>
</div>
)
}
@@ -1,11 +1,10 @@
import { SlackIcon } from '@/components/icons'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { CodeBlock } from '@/components/ui/code-block'
import { ConfigField } from '../ui/config-field'
import { Notice } from '@/components/ui/notice'
import { JSONView } from '@/app/w/[id]/components/panel/components/console/components/json-view/json-view'
import { ConfigSection } from '../ui/config-section'
import { CopyableField } from '../ui/copyable'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
import { WebhookConfigField } from '../ui/webhook-config-field'
interface SlackConfigProps {
signingSecret: string
@@ -19,6 +18,7 @@ interface SlackConfigProps {
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
webhookUrl: string
}
const exampleEvent = JSON.stringify(
@@ -46,28 +46,36 @@ export function SlackConfig({
testResult,
copied,
copyToClipboard,
webhookUrl,
}: SlackConfigProps) {
return (
<div className="space-y-4">
<ConfigSection title="Slack Configuration">
<ConfigField
<WebhookConfigField
id="webhook-url"
label="Webhook URL"
value={webhookUrl}
description="This is the URL that will receive webhook requests"
isLoading={isLoadingToken}
copied={copied}
copyType="url"
copyToClipboard={copyToClipboard}
readOnly={true}
/>
<WebhookConfigField
id="slack-signing-secret"
label="Signing Secret"
value={signingSecret}
onChange={setSigningSecret}
placeholder="Enter your Slack app signing secret"
description="Found on your Slack app's Basic Information page. Used to validate requests."
>
<CopyableField
id="slack-signing-secret"
value={signingSecret}
onChange={setSigningSecret}
placeholder="Enter your Slack app signing secret"
isLoading={isLoadingToken}
copied={copied}
copyType="slack-signing-secret"
copyToClipboard={copyToClipboard}
readOnly={false}
isSecret
/>
</ConfigField>
isLoading={isLoadingToken}
copied={copied}
copyType="slack-signing-secret"
copyToClipboard={copyToClipboard}
isSecret={true}
/>
</ConfigSection>
<TestResultDisplay
@@ -77,40 +85,73 @@ export function SlackConfig({
showCurlCommand={true}
/>
<InstructionsSection tip="Slack will verify the Request URL before enabling events.">
<ol className="list-decimal list-inside space-y-1">
<InstructionsSection>
<ol className="list-decimal list-inside space-y-2">
<li>
Go to your{' '}
Go to{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="link"
className="link text-primary underline hover:text-primary/80 transition-colors"
onClick={(e) => {
e.stopPropagation()
window.open('https://api.slack.com/apps', '_blank', 'noopener,noreferrer')
e.preventDefault()
}}
>
Slack Apps page
</a>
.
</li>
<li>Select your app or create a new one.</li>
<li>Navigate to "Event Subscriptions" and enable events.</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Request URL" field.
If you don't have an app:
<ol className="list-disc ml-5 mt-1">
<li>Create an app from scratch</li>
<li>Give it a name and select your workspace</li>
</ol>
</li>
<li>
Go to "Basic Information", find the "Signing Secret", and paste it in the field above.
</li>
<li>
Go to "OAuth & Permissions" and add bot token scopes:
<ol className="list-disc ml-5 mt-1">
<li>
<code>app_mentions:read</code> - For viewing messages that tag your bot with an @
</li>
<li>
<code>chat:write</code> - To send messages to channels your bot is a part of
</li>
</ol>
</li>
<li>
Go to "Event Subscriptions":
<ol className="list-disc ml-5 mt-1">
<li>Enable events</li>
<li>
Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages
that mention your bot
</li>
<li>Paste the Webhook URL (from above) into the "Request URL" field</li>
</ol>
</li>
<li>Subscribe to the workspace events you need (e.g., `message.channels`).</li>
<li>Go to "Basic Information", find the "Signing Secret", and copy it.</li>
<li>Paste the Signing Secret into the field above.</li>
<li>Save changes in both Slack and here.</li>
</ol>
</InstructionsSection>
<Alert>
<SlackIcon className="h-4 w-4" />
<AlertTitle>Slack Event Payload Example</AlertTitle>
<AlertDescription>
Your workflow will receive a payload similar to this when a subscribed event occurs:
<CodeBlock language="json" code={exampleEvent} className="mt-2 text-sm" />
</AlertDescription>
</Alert>
<Notice
variant="default"
className="bg-white border-slate-200 dark:bg-background dark:border-border"
icon={
<SlackIcon className="h-5 w-5 text-[#611f69] dark:text-[#e01e5a] mt-0.5 mr-3.5 flex-shrink-0" />
}
title="Slack Event Payload Example"
>
Your workflow will receive a payload similar to this when a subscribed event occurs:
<div className="mt-2 text-sm font-mono break-normal whitespace-normal overflow-wrap-anywhere">
<JSONView data={JSON.parse(exampleEvent)} initiallyExpanded={true} />
</div>
</Notice>
</div>
)
}
@@ -1,4 +1,5 @@
import React from 'react'
import { cn } from '@/lib/utils'
interface ConfigSectionProps {
title?: string
@@ -8,8 +9,9 @@ interface ConfigSectionProps {
export function ConfigSection({ title, children, className }: ConfigSectionProps) {
return (
<div className={`space-y-4 rounded-md border border-border bg-card p-4 ${className}`}>
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
<div
className={cn('space-y-4 rounded-md border border-border bg-card p-4 shadow-sm', className)}
>
{children}
</div>
)
@@ -53,7 +53,11 @@ export function CopyableField({
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
className={cn('flex-1', isSecret ? 'pr-10' : '')}
className={cn(
'flex-1',
isSecret ? 'pr-10' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
readOnly={readOnly}
/>
{isSecret && (
@@ -61,7 +65,10 @@ export function CopyableField({
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground"
className={cn(
'absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground',
'hover:text-foreground hover:bg-transparent transition-colors'
)}
onClick={toggleShowSecret}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
@@ -77,7 +84,7 @@ export function CopyableField({
size="icon"
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
className="shrink-0"
className={cn('shrink-0', 'hover:bg-primary/5 transition-colors')}
aria-label="Copy value"
>
{copied === copyType ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
@@ -1,5 +1,6 @@
import React from 'react'
import { Lightbulb } from 'lucide-react'
import { cn } from '@/lib/utils'
interface InstructionsSectionProps {
title?: string
@@ -15,21 +16,11 @@ export function InstructionsSection({
className,
}: InstructionsSectionProps) {
return (
<div
className={`bg-muted/50 dark:bg-muted/20 p-4 rounded-md mt-4 border border-border ${className}`}
>
<div className={cn('bg-card/50 p-4 rounded-md mt-4 border border-border shadow-sm', className)}>
<h4 className="font-medium text-base mb-3">{title}</h4>
<div className="text-sm text-muted-foreground space-y-1">
<div className="text-sm text-muted-foreground space-y-1 [&_a]:text-primary [&_a]:underline [&_a]:hover:text-primary/80 [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono">
{children} {/* Instructions list goes here */}
</div>
{tip && (
<div className="mt-4 pt-3 border-t border-border">
<p className="text-sm text-muted-foreground flex items-center">
<Lightbulb className="h-4 w-4 text-yellow-500 dark:text-yellow-400 mr-2 flex-shrink-0" />
{tip}
</p>
</div>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { AlertTriangle, Check, CheckCircle, Copy } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Notice } from '@/components/ui/notice'
import { cn } from '@/lib/utils'
interface TestResultDisplayProps {
@@ -29,18 +29,12 @@ export function TestResultDisplay({
}: TestResultDisplayProps) {
if (!testResult) return null
const Icon = testResult.success ? CheckCircle : AlertTriangle
return (
<Alert
variant={testResult.success ? 'default' : 'destructive'}
className={cn(
testResult.success &&
'border-green-500/50 text-green-700 dark:border-green-500/60 dark:text-green-400 [&>svg]:text-green-500 dark:[&>svg]:text-green-400'
)}
<Notice
variant={testResult.success ? 'success' : 'error'}
title={testResult.success ? 'Webhook Test Successful' : 'Webhook Test Failed'}
>
<Icon className="h-4 w-4" />
<AlertDescription>
<div>
{testResult.message}
{showCurlCommand && testResult.success && testResult.test?.curlCommand && (
@@ -67,7 +61,7 @@ export function TestResultDisplay({
</pre>
</div>
)}
</AlertDescription>
</Alert>
</div>
</Notice>
)
}
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { CheckCheck, Copy, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface WebhookConfigFieldProps {
id: string
label: string
value: string
onChange?: (value: string) => void
placeholder?: string
description?: string
isLoading?: boolean
copied: string | null
copyType: string
copyToClipboard: (text: string, type: string) => void
readOnly?: boolean
isSecret?: boolean
className?: string
}
export function WebhookConfigField({
id,
label,
value,
onChange,
placeholder,
description,
isLoading = false,
copied,
copyType,
copyToClipboard,
readOnly = false,
isSecret = false,
className,
}: WebhookConfigFieldProps) {
const [showSecret, setShowSecret] = useState(!isSecret)
const toggleShowSecret = () => {
if (isSecret) {
setShowSecret(!showSecret)
}
}
return (
<div className={cn('space-y-1 mb-4', className)}>
<Label htmlFor={id} className="text-sm font-medium">
{label}
</Label>
<div className="flex">
<div className={cn('flex-1 relative')}>
<Input
id={id}
type={isSecret && !showSecret ? 'password' : 'text'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
className={cn(
'flex-1 h-10',
readOnly ? 'font-mono text-xs cursor-text' : '',
isSecret ? 'pr-10' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={readOnly ? (e) => (e.target as HTMLInputElement).select() : undefined}
readOnly={readOnly}
disabled={isLoading}
/>
{isSecret && (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground',
'hover:text-foreground hover:bg-transparent transition-colors'
)}
onClick={toggleShowSecret}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
)}
</div>
<Button
type="button"
size="icon"
variant="outline"
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
>
{copied === copyType ? (
<CheckCheck className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
</div>
)
}
@@ -1,9 +1,9 @@
import { Loader2, Trash2 } from 'lucide-react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
interface WebhookDialogFooterProps {
webhookId: string | undefined
webhookId?: string
webhookProvider: string
isSaving: boolean
isDeleting: boolean
@@ -12,13 +12,12 @@ interface WebhookDialogFooterProps {
isCurrentConfigValid: boolean
onSave: () => void
onDelete: () => void
onTest?: () => void
onTest: () => void
onClose: () => void
}
export function WebhookDialogFooter({
webhookId,
webhookProvider,
isSaving,
isDeleting,
isLoadingToken,
@@ -29,71 +28,62 @@ export function WebhookDialogFooter({
onTest,
onClose,
}: WebhookDialogFooterProps) {
const showTestButton =
webhookId &&
(webhookProvider === 'whatsapp' ||
webhookProvider === 'generic' ||
webhookProvider === 'slack' ||
webhookProvider === 'airtable') &&
onTest
return (
<DialogFooter className="flex justify-between sticky bottom-0 py-3 bg-background border-t z-10 mt-auto w-full">
<div className="flex w-full justify-between">
<div>
{webhookId && (
<div className="flex space-x-3">
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeleting || isLoadingToken}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
</Button>
{showTestButton && (
<Button variant="outline" onClick={onTest} disabled={isTesting || isLoadingToken}>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
'Test'
)}
</Button>
<Button
type="button"
variant="destructive"
onClick={onDelete}
disabled={isDeleting || isSaving || isLoadingToken}
size="default"
className="h-10"
>
{isDeleting ? (
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
</div>
{isDeleting ? 'Deleting...' : 'Delete Webhook'}
</Button>
)}
</div>
<div className="flex space-x-3">
<Button variant="outline" onClick={onClose}>
<div className="flex gap-2">
{webhookId && (
<Button
type="button"
variant="outline"
onClick={onTest}
disabled={isTesting || isSaving || isDeleting || isLoadingToken || !webhookId}
className="h-10"
>
{isTesting && (
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent mr-2" />
)}
{isTesting ? 'Testing...' : 'Test Webhook'}
</Button>
)}
<Button variant="outline" onClick={onClose} size="default" className="h-10">
Cancel
</Button>
<Button
variant="default"
onClick={onSave}
disabled={isSaving || isLoadingToken || !isCurrentConfigValid}
className="bg-primary"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
disabled={isLoadingToken || isSaving || !isCurrentConfigValid}
className={cn(
'h-10',
!isLoadingToken && isCurrentConfigValid ? 'bg-primary hover:bg-primary/90' : '',
isSaving &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20'
)}
size="default"
>
{isSaving && (
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent mr-2" />
)}
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
</div>
)
}
@@ -1,53 +0,0 @@
import { Badge } from '@/components/ui/badge'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { WEBHOOK_PROVIDERS } from '../../webhook-config'
interface WebhookDialogHeaderProps {
webhookProvider: string
webhookId: string | undefined
}
export function WebhookDialogHeader({ webhookProvider, webhookId }: WebhookDialogHeaderProps) {
const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
// Get provider icon
const getProviderIcon = () => {
return provider.icon({
className:
webhookProvider === 'github' || webhookProvider === 'discord'
? 'h-5 w-5'
: 'h-5 w-5 text-green-500 dark:text-green-400',
})
}
// Get provider-specific title
const getProviderTitle = () => {
return `${provider.name} Integration`
}
return (
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="mr-3 flex items-center">{getProviderIcon()}</div>
<div>
<DialogTitle>{getProviderTitle()}</DialogTitle>
<DialogDescription>
{webhookProvider === 'generic'
? 'Configure your webhook integration'
: `Configure your ${provider.name.toLowerCase()} integration`}
</DialogDescription>
</div>
</div>
<div className="flex items-center space-x-2">
{webhookId && (
<Badge
variant="outline"
className="bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800"
>
Connected
</Badge>
)}
</div>
</DialogHeader>
)
}
@@ -1,7 +1,9 @@
import { Check, Copy, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { CheckCheck, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface WebhookUrlFieldProps {
webhookUrl: string
@@ -17,27 +19,40 @@ export function WebhookUrlField({
copyToClipboard,
}: WebhookUrlFieldProps) {
return (
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<div className="flex items-center space-x-2 pr-1">
{isLoadingToken ? (
<div className="flex-1 h-10 px-3 py-2 rounded-md border border-input bg-background flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<Input id="webhook-url" value={webhookUrl} readOnly className="flex-1" />
)}
<div className="space-y-1 mb-4">
<Label htmlFor="webhook-url" className="text-sm font-medium">
Webhook URL
</Label>
<div className="flex">
<Input
id="webhook-url"
readOnly
value={webhookUrl}
className={cn(
'flex-1 h-10 font-mono text-xs cursor-text',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={(e) => (e.target as HTMLInputElement).select()}
disabled={isLoadingToken}
/>
<Button
type="button"
variant="outline"
size="icon"
variant="outline"
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(webhookUrl, 'url')}
disabled={isLoadingToken}
className="ml-1"
>
{copied === 'url' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied === 'url' ? (
<CheckCheck className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
This is the URL that will receive webhook requests
</p>
</div>
)
}
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { ProviderConfig, WEBHOOK_PROVIDERS } from '../webhook-config'
import { AirtableConfig } from './providers/airtable-config'
import { DiscordConfig } from './providers/discord-config'
@@ -12,7 +21,6 @@ import { WhatsAppConfig } from './providers/whatsapp-config'
import { DeleteConfirmDialog } from './ui/confirmation'
import { UnsavedChangesDialog } from './ui/confirmation'
import { WebhookDialogFooter } from './ui/webhook-footer'
import { WebhookDialogHeader } from './ui/webhook-header'
import { WebhookUrlField } from './ui/webhook-url'
const logger = createLogger('WebhookModal')
@@ -304,7 +312,7 @@ export function WebhookModal({
const webhookUrl = `${baseUrl}/api/webhooks/trigger/${formattedPath}`
const copyToClipboard = (text: string, type: string) => {
const copyToClipboard = (text: string, type: string): void => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
@@ -568,6 +576,7 @@ export function WebhookModal({
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookUrl={webhookUrl}
/>
)
case 'airtable':
@@ -585,6 +594,7 @@ export function WebhookModal({
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookId={webhookId}
webhookUrl={webhookUrl}
/>
)
case 'generic':
@@ -609,36 +619,59 @@ export function WebhookModal({
}
}
// Get provider name for the title
const getProviderTitle = () => {
const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
return provider.name || 'Webhook'
}
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[675px] max-h-[90vh] overflow-hidden flex flex-col">
<WebhookDialogHeader webhookProvider={webhookProvider} webhookId={webhookId} />
<DialogContent
className="sm:max-w-[600px] flex flex-col p-0 gap-0 max-h-[90vh] overflow-hidden"
hideCloseButton
>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">
{webhookId ? 'Edit' : 'Configure'} {getProviderTitle()} Webhook
</DialogTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</DialogHeader>
<div className="space-y-4 py-2 px-2 overflow-y-auto flex-grow pb-6">
<WebhookUrlField
webhookUrl={webhookUrl}
isLoadingToken={isLoadingToken}
copied={copied}
copyToClipboard={copyToClipboard}
/>
<div className="pt-4 px-6 pb-6 overflow-y-auto flex-grow">
{webhookProvider !== 'slack' && webhookProvider !== 'airtable' && (
<WebhookUrlField
webhookUrl={webhookUrl}
isLoadingToken={isLoadingToken}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
{renderProviderContent()}
</div>
<WebhookDialogFooter
webhookId={webhookId}
webhookProvider={webhookProvider}
isSaving={isSaving}
isDeleting={isDeleting}
isLoadingToken={isLoadingToken}
isTesting={isTesting}
isCurrentConfigValid={isCurrentConfigValid} // <-- Pass down validation state
onSave={handleSave}
onDelete={() => setShowDeleteConfirm(true)}
onTest={testWebhook}
onClose={handleClose}
/>
<DialogFooter className="px-6 pt-0 pb-6 w-full border-t pt-4">
<WebhookDialogFooter
webhookId={webhookId}
webhookProvider={webhookProvider}
isSaving={isSaving}
isDeleting={isDeleting}
isLoadingToken={isLoadingToken}
isTesting={isTesting}
isCurrentConfigValid={isCurrentConfigValid}
onSave={handleSave}
onDelete={() => setShowDeleteConfirm(true)}
onTest={testWebhook}
onClose={handleClose}
/>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -11,6 +11,7 @@ import {
} from '@/components/icons'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { WebhookModal } from './components/webhook-modal'
@@ -137,7 +138,18 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
generic: {
id: 'generic',
name: 'General',
icon: (props) => <CheckCircle2 {...props} />,
icon: (props) => (
<div
className={`flex items-center justify-center rounded ${props.className || ''}`}
style={{
backgroundColor: '#802FFF',
minWidth: '28px',
padding: '0 4px',
}}
>
<span className="font-medium text-white text-xs">Sim</span>
</div>
),
configFields: {
token: {
type: 'string',
@@ -216,6 +228,10 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
const [webhookId, setWebhookId] = useState<string | null>(null)
const params = useParams()
const workflowId = params.id as string
const [isLoading, setIsLoading] = useState(false)
// Get workflow store function to update webhook status
const setWebhookStatus = useWorkflowStore((state) => state.setWebhookStatus)
// Get the webhook provider from the block state
const [webhookProvider, setWebhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
@@ -232,6 +248,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
// Check if webhook exists in the database
useEffect(() => {
const checkWebhook = async () => {
setIsLoading(true)
try {
// Check if there's a webhook for this workflow
const response = await fetch(`/api/webhooks?workflowId=${workflowId}`)
@@ -253,18 +270,33 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
if (webhook.path && webhook.path !== webhookPath) {
setWebhookPath(webhook.path)
}
// Set active webhook flag to true since we found an active webhook
setWebhookStatus(true)
} else {
setWebhookId(null)
setActualProvider(null)
// Set active webhook flag to false since no webhook was found
setWebhookStatus(false)
}
}
} catch (error) {
logger.error('Error checking webhook:', { error })
} finally {
setIsLoading(false)
}
}
checkWebhook()
}, [webhookPath, webhookProvider, workflowId, setWebhookPath, setWebhookProvider])
}, [
webhookPath,
webhookProvider,
workflowId,
setWebhookPath,
setWebhookProvider,
setWebhookStatus,
])
const handleOpenModal = () => {
setIsModalOpen(true)
@@ -317,6 +349,9 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
// Update the actual provider after saving
setActualProvider(webhookProvider || 'generic')
// Set active webhook flag to true after successfully saving
setWebhookStatus(true)
return true
} catch (error: any) {
logger.error('Error saving webhook:', { error })
@@ -347,6 +382,10 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
setWebhookId(null)
setActualProvider(null)
// Set active webhook flag to false after deletion
setWebhookStatus(false)
handleCloseModal()
return true
} catch (error: any) {
logger.error('Error deleting webhook:', { error })
@@ -374,27 +413,41 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
const isWebhookConnected = webhookId && webhookProvider === actualProvider
return (
<div className="mt-2">
<div className="w-full">
{error && <div className="text-sm text-red-500 dark:text-red-400 mb-2">{error}</div>}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
>
{isWebhookConnected ? (
<>
{getProviderIcon()}
{isSaving ? 'Saving...' : isDeleting ? 'Deleting...' : 'Webhook Connected'}
</>
) : (
<>
{isWebhookConnected ? (
<div className="flex flex-col space-y-2">
<div
className="flex items-center justify-center px-3 py-2 rounded border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors duration-200 cursor-pointer h-10"
onClick={handleOpenModal}
>
<div className="flex items-center gap-2">
<div className="flex items-center">
{getProviderIcon()}
<span className="font-normal text-sm">
{WEBHOOK_PROVIDERS[webhookProvider || 'generic'].name} Webhook
</span>
</div>
</div>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-10 text-sm font-normal bg-background flex items-center"
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
>
{isLoading ? (
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
) : (
<ExternalLink className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : isDeleting ? 'Deleting...' : 'Configure Webhook'}
</>
)}
</Button>
)}
Configure Webhook
</Button>
)}
{isModalOpen && (
<WebhookModal
@@ -1,17 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { Calendar, Info, RectangleHorizontal, RectangleVertical } from 'lucide-react'
import { Calendar, ExternalLink, Info, RectangleHorizontal, RectangleVertical } from 'lucide-react'
import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, formatDateTime } from '@/lib/utils'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { ActionBar } from './components/action-bar/action-bar'
import { ConnectionBlocks } from './components/connection-blocks/connection-blocks'
import { SubBlock } from './components/sub-block/sub-block'
@@ -37,6 +37,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
nextRunAt: string | null
lastRanAt: string | null
} | null>(null)
const [webhookInfo, setWebhookInfo] = useState<{
webhookPath: string
provider: string
} | null>(null)
// Refs
const blockRef = useRef<HTMLDivElement>(null)
@@ -52,6 +56,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
const hasActiveSchedule = useWorkflowStore((state) => state.hasActiveSchedule ?? false)
const hasActiveWebhook = useWorkflowStore((state) => state.hasActiveWebhook ?? false)
// Workflow store actions
const updateBlockName = useWorkflowStore((state) => state.updateBlockName)
@@ -65,7 +70,6 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
// Get schedule information for the tooltip
useEffect(() => {
if (type === 'starter' && hasActiveSchedule) {
// Fetch schedule information
const fetchScheduleInfo = async () => {
try {
const workflowId = useWorkflowRegistry.getState().activeWorkflowId
@@ -80,7 +84,6 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
if (response.ok) {
const data = await response.json()
if (data.schedule) {
let scheduleTiming = 'Unknown schedule'
if (data.schedule.cronExpression) {
@@ -100,9 +103,41 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
fetchScheduleInfo()
} else if (!hasActiveSchedule) {
setScheduleInfo(null)
}
}, [type, hasActiveSchedule])
// Get webhook information for the tooltip
useEffect(() => {
if (type === 'starter' && hasActiveWebhook) {
const fetchWebhookInfo = async () => {
try {
const workflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!workflowId) return
const response = await fetch(`/api/webhooks?workflowId=${workflowId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks?.[0]?.webhook) {
const webhook = data.webhooks[0].webhook
setWebhookInfo({
webhookPath: webhook.path || '',
provider: webhook.provider || 'generic',
})
}
}
} catch (error) {
console.error('Error fetching webhook info:', error)
}
}
fetchWebhookInfo()
} else if (!hasActiveWebhook) {
setWebhookInfo(null)
}
}, [type, hasActiveWebhook])
// Update node internals when handles change
useEffect(() => {
updateNodeInternals(id)
@@ -249,9 +284,24 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
// Check if this is a starter block and has an active schedule
// Check if this is a starter block and has active schedule or webhook
const isStarterBlock = type === 'starter'
const showScheduleIndicator = isStarterBlock && hasActiveSchedule
const showWebhookIndicator = isStarterBlock && hasActiveWebhook
// Helper function to get provider name - only create once
const getProviderName = (providerId: string): string => {
const providers: Record<string, string> = {
whatsapp: 'WhatsApp',
github: 'GitHub',
discord: 'Discord',
stripe: 'Stripe',
generic: 'General',
slack: 'Slack',
airtable: 'Airtable',
}
return providers[providerId] || 'Webhook'
}
return (
<div className="relative group">
@@ -363,32 +413,56 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-4">
<div className="space-y-2">
<div>
<p className="text-sm font-medium mb-1">Schedule Information</p>
{scheduleInfo ? (
<>
<p className="text-sm mb-1">{scheduleInfo.scheduleTiming}</p>
<div className="text-xs text-muted-foreground">
{scheduleInfo.nextRunAt && (
<div>
Next run: {formatDateTime(new Date(scheduleInfo.nextRunAt))}
</div>
)}
{scheduleInfo.lastRanAt && (
<div>
Last run: {formatDateTime(new Date(scheduleInfo.lastRanAt))}
</div>
)}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
This workflow is running on a schedule.
{scheduleInfo ? (
<>
<p className="text-sm">{scheduleInfo.scheduleTiming}</p>
{scheduleInfo.nextRunAt && (
<p className="text-xs text-muted-foreground mt-1">
Next run: {formatDateTime(new Date(scheduleInfo.nextRunAt))}
</p>
)}
{scheduleInfo.lastRanAt && (
<p className="text-xs text-muted-foreground">
Last run: {formatDateTime(new Date(scheduleInfo.lastRanAt))}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
This workflow is running on a schedule.
</p>
)}
</TooltipContent>
</Tooltip>
)}
{/* Webhook indicator badge - displayed for starter blocks with active webhooks */}
{showWebhookIndicator && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="flex items-center gap-1 text-green-600 bg-green-50 border-green-200 hover:bg-green-50 dark:bg-green-900/20 dark:text-green-400 font-normal text-xs"
>
<div className="relative flex items-center justify-center mr-0.5">
<div className="absolute h-3 w-3 rounded-full bg-green-500/20"></div>
<div className="relative h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
Webhook
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-4">
{webhookInfo ? (
<>
<p className="text-sm">{getProviderName(webhookInfo.provider)} Webhook</p>
<p className="text-xs text-muted-foreground mt-1">
Path: {webhookInfo.webhookPath}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
This workflow is triggered by a webhook.
</p>
)}
</TooltipContent>
</Tooltip>
)}
+4 -4
View File
@@ -46,12 +46,12 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Generic', id: 'generic' },
{ label: 'WhatsApp', id: 'whatsapp' },
{ label: 'GitHub', id: 'github' },
{ label: 'Discord', id: 'discord' },
{ label: 'Slack', id: 'slack' },
{ label: 'Airtable', id: 'airtable' },
{ label: 'Generic', id: 'generic' },
// { label: 'WhatsApp', id: 'whatsapp' },
// { label: 'GitHub', id: 'github' },
// { label: 'Discord', id: 'discord' },
// { label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',
+1 -1
View File
@@ -5,7 +5,7 @@ import { BlockCategory, BlockConfig, BlockIcon } from '../types'
export const TwilioSMSBlock: BlockConfig<TwilioSMSBlockOutput> = {
type: 'twilio_sms',
name: 'Twilio SMS',
description: 'Send SMS messages via Twilio',
description: 'Send SMS messages',
longDescription: 'Send text messages to single or multiple recipients using the Twilio API.',
category: 'tools',
bgColor: '#F22F46', // Twilio brand color
+64
View File
@@ -0,0 +1,64 @@
import React from 'react'
import { AlertCircle, AlertTriangle, Check, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
export type NoticeVariant = 'info' | 'warning' | 'success' | 'error' | 'default'
interface NoticeProps {
children: React.ReactNode
variant?: NoticeVariant
className?: string
icon?: React.ReactNode
title?: string
}
const variantStyles = {
default: {
container: 'bg-background border-border',
text: 'text-foreground',
title: 'text-foreground font-medium',
icon: <Info className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />,
},
info: {
container: 'bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800/50',
text: 'text-blue-800 dark:text-blue-300',
title: 'text-blue-800 dark:text-blue-300 font-medium',
icon: <Info className="h-4 w-4 text-blue-500 dark:text-blue-400 mr-2 flex-shrink-0" />,
},
warning: {
container: 'bg-amber-50 border-amber-200 dark:bg-amber-950/20 dark:border-amber-800/50',
text: 'text-amber-800 dark:text-amber-300',
title: 'text-amber-800 dark:text-amber-300 font-medium',
icon: (
<AlertTriangle className="h-4 w-4 text-amber-500 dark:text-amber-400 mr-2 flex-shrink-0" />
),
},
success: {
container: 'bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800/50',
text: 'text-green-800 dark:text-green-300',
title: 'text-green-800 dark:text-green-300 font-medium',
icon: <Check className="h-4 w-4 text-green-500 dark:text-green-400 mr-2 flex-shrink-0" />,
},
error: {
container: 'bg-red-50 border-red-200 dark:bg-red-950/20 dark:border-red-800/50',
text: 'text-red-800 dark:text-red-300',
title: 'text-red-800 dark:text-red-300 font-medium',
icon: <AlertCircle className="h-4 w-4 text-red-500 dark:text-red-400 mr-2 flex-shrink-0" />,
},
}
export function Notice({ children, variant = 'info', className, icon, title }: NoticeProps) {
const styles = variantStyles[variant]
return (
<div className={cn('rounded-md border p-3 flex', styles.container, className)}>
<div className="flex items-start">
{icon || styles.icon}
<div className="flex-1">
{title && <div className={cn('mb-1', styles.title)}>{title}</div>}
<div className={cn('text-sm', styles.text)}>{children}</div>
</div>
</div>
</div>
)
}
-19
View File
@@ -443,18 +443,6 @@ export class Executor {
},
}
logger.info(`[Executor] API input for starter block:`, {
type: typeof inputData,
isArray: Array.isArray(inputData),
keys: Object.keys(inputData),
inputData: JSON.stringify(inputData, null, 2),
})
logger.info(
`[Executor] Final starter block output:`,
JSON.stringify(starterOutput, null, 2)
)
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
@@ -468,8 +456,6 @@ export class Executor {
},
}
logger.info(`[Executor] Simple starter output:`, JSON.stringify(starterOutput, null, 2))
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
@@ -723,11 +709,6 @@ export class Executor {
logger.warn(
`Starter block state not found when executing ${block.metadata?.name || blockId}. This may cause reference errors.`
)
} else {
logger.debug(
`Starter block state available for ${block.metadata?.name || blockId}:`,
JSON.stringify(starterState.output || {}, null, 2)
)
}
}
+971
View File
@@ -0,0 +1,971 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionError, persistExecutionLogs } from '@/lib/logs/execution-logger'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { decryptSecret } from '@/lib/utils'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { mergeSubblockStateAsync } from '@/stores/workflows/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { environment, userStats, webhook, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import { Serializer } from '@/serializer'
import { getRedisClient, hasProcessedMessage, markMessageAsProcessed, acquireLock } from '@/lib/redis'
const logger = createLogger('WebhookUtils')
/**
* Handle WhatsApp verification requests
*/
export async function handleWhatsAppVerification(
requestId: string,
path: string,
mode: string | null,
token: string | null,
challenge: string | null
): Promise<NextResponse | null> {
if (mode && token && challenge) {
// This is a WhatsApp verification request
logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`)
if (mode !== 'subscribe') {
logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`)
return new NextResponse('Invalid mode', { status: 400 })
}
// Find all active WhatsApp webhooks
const webhooks = await db
.select()
.from(webhook)
.where(and(eq(webhook.provider, 'whatsapp'), eq(webhook.isActive, true)))
// Check if any webhook has a matching verification token
for (const wh of webhooks) {
const providerConfig = (wh.providerConfig as Record<string, any>) || {}
const verificationToken = providerConfig.verificationToken
if (!verificationToken) {
logger.debug(`[${requestId}] Webhook ${wh.id} has no verification token, skipping`)
continue
}
if (token === verificationToken) {
logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`)
// Return ONLY the challenge as plain text (exactly as WhatsApp expects)
return new NextResponse(challenge, {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
logger.warn(`[${requestId}] No matching WhatsApp verification token found`)
return new NextResponse('Verification failed', { status: 403 })
}
return null
}
/**
* Handle Slack verification challenges
*/
export function handleSlackChallenge(
body: any
): NextResponse | null {
if (body.type === 'url_verification' && body.challenge) {
return NextResponse.json({ challenge: body.challenge })
}
return null
}
/**
* Validates a Slack webhook request signature using HMAC SHA-256
* @param signingSecret - Slack signing secret for validation
* @param signature - X-Slack-Signature header value
* @param timestamp - X-Slack-Request-Timestamp header value
* @param body - Raw request body string
* @returns Whether the signature is valid
*/
export async function validateSlackSignature(
signingSecret: string,
signature: string,
timestamp: string,
body: string
): Promise<boolean> {
try {
// Basic validation first
if (!signingSecret || !signature || !timestamp || !body) {
return false
}
// Check if the timestamp is too old (> 5 minutes)
const currentTime = Math.floor(Date.now() / 1000)
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false
}
// Compute the signature
const encoder = new TextEncoder()
const baseString = `v0:${timestamp}:${body}`
// Create the HMAC with the signing secret
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(signingSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(baseString))
// Convert the signature to hex
const signatureHex = Array.from(new Uint8Array(signatureBytes))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
// Prepare the expected signature format
const computedSignature = `v0=${signatureHex}`
// Constant-time comparison to prevent timing attacks
if (computedSignature.length !== signature.length) {
return false
}
let result = 0
for (let i = 0; i < computedSignature.length; i++) {
result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i)
}
return result === 0
} catch (error) {
console.error('Error validating Slack signature:', error)
return false
}
}
/**
* Process WhatsApp message deduplication
*/
export async function processWhatsAppDeduplication(
requestId: string,
messages: any[]
): Promise<NextResponse | null> {
if (messages.length > 0) {
const message = messages[0]
const messageId = message.id
if (messageId) {
const whatsappMsgKey = `whatsapp:msg:${messageId}`
try {
const isDuplicate = await hasProcessedMessage(whatsappMsgKey)
if (isDuplicate) {
logger.info(`[${requestId}] Duplicate WhatsApp message detected: ${messageId}`)
return new NextResponse('Duplicate message', { status: 200 })
}
// Mark as processed BEFORE processing
await markMessageAsProcessed(whatsappMsgKey, 60 * 60 * 24)
} catch (error) {
logger.error(`[${requestId}] Error in WhatsApp deduplication`, error)
// Continue processing
}
}
}
return null
}
/**
* Process generic deduplication using request hash
*/
export async function processGenericDeduplication(
requestId: string,
path: string,
body: any
): Promise<NextResponse | null> {
try {
const requestHash = await generateRequestHash(path, body)
const genericMsgKey = `generic:${requestHash}`
const isDuplicate = await hasProcessedMessage(genericMsgKey)
if (isDuplicate) {
logger.info(`[${requestId}] Duplicate request detected with hash: ${requestHash}`)
return new NextResponse('Duplicate request', { status: 200 })
}
// Mark as processed
await markMessageAsProcessed(genericMsgKey, 60 * 60 * 24)
} catch (error) {
logger.error(`[${requestId}] Error in generic deduplication`, error)
// Continue processing
}
return null
}
/**
* Format webhook input based on provider
*/
export function formatWebhookInput(
foundWebhook: any,
foundWorkflow: any,
body: any,
request: NextRequest
): any {
if (foundWebhook.provider === 'whatsapp') {
// WhatsApp input formatting logic
const data = body?.entry?.[0]?.changes?.[0]?.value
const messages = data?.messages || []
if (messages.length > 0) {
const message = messages[0]
const phoneNumberId = data.metadata?.phone_number_id
const from = message.from
const messageId = message.id
const timestamp = message.timestamp
const text = message.text?.body
return {
whatsapp: {
data: {
messageId,
from,
phoneNumberId,
text,
timestamp,
raw: message,
}
},
webhook: {
data: {
provider: 'whatsapp',
path: foundWebhook.path,
providerConfig: foundWebhook.providerConfig,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
}
},
workflowId: foundWorkflow.id
}
} else {
return null
}
} else {
// Generic format for Slack and other providers
return {
webhook: {
data: {
path: foundWebhook.path,
provider: foundWebhook.provider,
providerConfig: foundWebhook.providerConfig,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
}
},
workflowId: foundWorkflow.id
}
}
}
/**
* Execute workflow with the provided input
*/
export async function executeWorkflowFromPayload(
foundWorkflow: any,
input: any,
executionId: string,
requestId: string
): Promise<void> {
// Add log at the beginning of this function for clarity
logger.info(`[${requestId}] Preparing to execute workflow`, {
workflowId: foundWorkflow.id,
executionId,
triggerSource: 'webhook-payload',
})
// Returns void as errors are handled internally
try {
// Get the workflow state
if (!foundWorkflow.state) {
throw new Error(`Workflow ${foundWorkflow.id} has no state`)
}
const state = foundWorkflow.state as any
const { blocks, edges, loops } = state
logger.debug(
`[${requestId}] Merging subblock states for workflow ${foundWorkflow.id} (Execution: ${executionId})`
)
const mergedStates = await mergeSubblockStateAsync(blocks, foundWorkflow.id)
// Retrieve and decrypt environment variables
const [userEnv] = await db
.select()
.from(environment)
.where(eq(environment.userId, foundWorkflow.userId))
.limit(1)
let decryptedEnvVars: Record<string, string> = {}
if (userEnv) {
// Decryption logic
const decryptionPromises = Object.entries(userEnv.variables as Record<string, string>).map(
async ([key, encryptedValue]) => {
try {
const { decrypted } = await decryptSecret(encryptedValue)
return [key, decrypted] as const
} catch (error: any) {
logger.error(
`[${requestId}] Failed to decrypt environment variable "${key}" (Execution: ${executionId})`,
error
)
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
}
}
)
const decryptedEntries = await Promise.all(decryptionPromises)
decryptedEnvVars = Object.fromEntries(decryptedEntries)
}
// Process block states (extract subBlock values, parse responseFormat)
const currentBlockStates = Object.entries(mergedStates).reduce(
(acc, [id, block]) => {
acc[id] = Object.entries(block.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
const processedBlockStates = Object.entries(currentBlockStates).reduce(
(acc, [blockId, blockState]) => {
const processedState = { ...blockState }
if (processedState.responseFormat) {
try {
if (typeof processedState.responseFormat === 'string') {
processedState.responseFormat = JSON.parse(processedState.responseFormat)
}
if (
processedState.responseFormat &&
typeof processedState.responseFormat === 'object'
) {
if (!processedState.responseFormat.schema && !processedState.responseFormat.name) {
processedState.responseFormat = {
name: 'response_schema',
schema: processedState.responseFormat,
strict: true,
}
}
}
acc[blockId] = processedState
} catch (error) {
logger.warn(
`[${requestId}] Failed to parse responseFormat for block ${blockId} (Execution: ${executionId})`,
error
)
acc[blockId] = blockState
}
} else {
acc[blockId] = blockState
}
return acc
},
{} as Record<string, Record<string, any>>
)
// Serialize and get workflow variables
const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates as any, edges, loops)
let workflowVariables = {}
if (foundWorkflow.variables) {
try {
if (typeof foundWorkflow.variables === 'string') {
workflowVariables = JSON.parse(foundWorkflow.variables)
} else {
workflowVariables = foundWorkflow.variables
}
} catch (error) {
logger.error(
`[${requestId}] Failed to parse workflow variables: ${foundWorkflow.id} (Execution: ${executionId})`,
error
)
}
}
logger.debug(`[${requestId}] Starting workflow execution`, {
executionId,
blockCount: Object.keys(processedBlockStates).length,
})
const executor = new Executor(
serializedWorkflow,
processedBlockStates,
decryptedEnvVars,
input, // Use the provided input (might be single event or batch)
workflowVariables
)
const result = await executor.execute(foundWorkflow.id)
logger.info(`[${requestId}] Workflow execution finished`, {
executionId,
success: result.success,
durationMs: result.metadata?.duration,
})
// Update counts and stats if successful
if (result.success) {
await updateWorkflowRunCounts(foundWorkflow.id)
await db
.update(userStats)
.set({
totalWebhookTriggers: sql`total_webhook_triggers + 1`,
lastActive: new Date(),
})
.where(eq(userStats.userId, foundWorkflow.userId))
}
// Build and enrich result with trace spans
const { traceSpans, totalDuration } = buildTraceSpans(result)
const enrichedResult = { ...result, traceSpans, totalDuration }
// Persist logs for this execution using the standard 'webhook' trigger type
await persistExecutionLogs(foundWorkflow.id, executionId, enrichedResult, 'webhook')
} catch (error: any) {
logger.error(`[${requestId}] Error executing workflow`, {
workflowId: foundWorkflow.id,
executionId,
error: error.message,
stack: error.stack,
})
// Persist the error for this execution using the standard 'webhook' trigger type
await persistExecutionError(foundWorkflow.id, executionId, error, 'webhook')
// Re-throw the error so the caller (fetchAndProcessAirtablePayloads) knows it failed
throw error
}
}
/**
* Process webhook provider-specific verification
*/
export function verifyProviderWebhook(
foundWebhook: any,
request: NextRequest,
requestId: string
): NextResponse | null {
const authHeader = request.headers.get('authorization')
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
// Keep existing switch statement for github, stripe, generic, default
switch (foundWebhook.provider) {
case 'github':
break // No specific auth here
case 'stripe':
break // Stripe verification would go here
case 'generic':
// Generic auth logic: requireAuth, token, secretHeaderName, allowedIps
if (providerConfig.requireAuth) {
let isAuthenticated = false
// Check for token in Authorization header (Bearer token)
if (providerConfig.token) {
const providedToken = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: null
if (providedToken === providerConfig.token) {
isAuthenticated = true
}
// Check for token in custom header if specified
if (!isAuthenticated && providerConfig.secretHeaderName) {
const customHeaderValue = request.headers.get(providerConfig.secretHeaderName)
if (customHeaderValue === providerConfig.token) {
isAuthenticated = true
}
}
// Return 401 if authentication failed
if (!isAuthenticated) {
logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`)
return new NextResponse('Unauthorized', { status: 401 })
}
}
}
// IP restriction check
if (
providerConfig.allowedIps &&
Array.isArray(providerConfig.allowedIps) &&
providerConfig.allowedIps.length > 0
) {
const clientIp =
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown'
if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) {
logger.warn(
`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`
)
return new NextResponse('Forbidden - IP not allowed', { status: 403 })
}
}
break
default:
if (providerConfig.token) {
const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null
if (!providedToken || providedToken !== providerConfig.token) {
logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`)
return new NextResponse('Unauthorized', { status: 401 })
}
}
}
return null
}
/**
* Process Airtable payloads
*/
export async function fetchAndProcessAirtablePayloads(
webhookData: any,
workflowData: any,
requestId: string // Original request ID from the ping, used for the final execution log
) {
// Use a prefix derived from requestId for *internal* polling logs/errors
const internalPollIdPrefix = `poll-${requestId}`
let currentCursor: number | null = null
let mightHaveMore = true
let payloadsFetched = 0 // Track total payloads fetched
let apiCallCount = 0
// Use a Map to consolidate changes per record ID
const consolidatedChangesMap = new Map<string, AirtableChange>()
let localProviderConfig = { ...((webhookData.providerConfig as Record<string, any>) || {}) } // Local copy
try {
// --- Essential IDs & Config from localProviderConfig ---
const baseId = localProviderConfig.baseId
const airtableWebhookId = localProviderConfig.externalId
if (!baseId || !airtableWebhookId) {
logger.error(
`[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.`
)
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-config-error`,
new Error('Missing Airtable baseId or externalId in providerConfig'),
'webhook'
)
return // Exit early
}
// --- Retrieve Stored Cursor from localProviderConfig ---
const storedCursor = localProviderConfig.externalWebhookCursor
// Initialize cursor in provider config if missing
if (storedCursor === undefined || storedCursor === null) {
// Update the local copy
localProviderConfig.externalWebhookCursor = null
// Add cursor to the database immediately to fix the configuration
try {
await db
.update(webhook)
.set({
providerConfig: { ...localProviderConfig, externalWebhookCursor: null },
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookData.id))
localProviderConfig.externalWebhookCursor = null // Update local copy too
} catch (initError: any) {
logger.error(`[${requestId}] Failed to initialize cursor in DB`, {
webhookId: webhookData.id,
error: initError.message,
stack: initError.stack,
})
// Persist the error specifically for cursor initialization failure
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-cursor-init-error`,
initError,
'webhook'
)
}
}
if (storedCursor && typeof storedCursor === 'number') {
currentCursor = storedCursor
} else {
currentCursor = null // Airtable API defaults to 1 if omitted
}
// --- Get OAuth Token ---
let accessToken: string | null = null
try {
accessToken = await getOAuthToken(workflowData.userId, 'airtable')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain valid Airtable access token. Cannot proceed.`,
{ userId: workflowData.userId }
)
throw new Error('Airtable access token not found.')
}
logger.info(`[${requestId}] Successfully obtained Airtable access token`)
} catch (tokenError: any) {
logger.error(
`[${requestId}] Failed to get Airtable OAuth token for user ${workflowData.userId}`,
{
error: tokenError.message,
stack: tokenError.stack,
userId: workflowData.userId,
}
)
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-token-error`,
tokenError,
'webhook'
)
return // Exit early
}
const airtableApiBase = 'https://api.airtable.com/v0'
// --- Polling Loop ---
while (mightHaveMore) {
apiCallCount++
// Safety break
if (apiCallCount > 10) {
logger.warn(`[${requestId}] Reached maximum polling limit (10 calls)`, {
webhookId: webhookData.id,
consolidatedCount: consolidatedChangesMap.size,
})
mightHaveMore = false
break
}
const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads`
const queryParams = new URLSearchParams()
if (currentCursor !== null) {
queryParams.set('cursor', currentCursor.toString())
}
const fullUrl = `${apiUrl}?${queryParams.toString()}`
try {
const response = await fetch(fullUrl, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
})
const responseBody = await response.json()
if (!response.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
`Airtable API error Status ${response.status}`
logger.error(
`[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`,
{ webhookId: webhookData.id, status: response.status, error: errorMessage }
)
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-api-error-${apiCallCount}`,
new Error(`Airtable API Error: ${errorMessage}`),
'webhook'
)
mightHaveMore = false
break
}
const receivedPayloads = responseBody.payloads || []
// --- Process and Consolidate Changes ---
if (receivedPayloads.length > 0) {
payloadsFetched += receivedPayloads.length
for (const payload of receivedPayloads) {
if (payload.changedTablesById) {
for (const [tableId, tableChangesUntyped] of Object.entries(
payload.changedTablesById
)) {
const tableChanges = tableChangesUntyped as any // Assert type
// Handle created records
if (tableChanges.createdRecordsById) {
for (const [recordId, recordDataUntyped] of Object.entries(
tableChanges.createdRecordsById
)) {
const recordData = recordDataUntyped as any // Assert type
const existingChange = consolidatedChangesMap.get(recordId)
if (existingChange) {
// Record was created and possibly updated within the same batch
existingChange.changedFields = {
...existingChange.changedFields,
...(recordData.cellValuesByFieldId || {}),
}
// Keep changeType as 'created' if it started as created
} else {
// New creation
consolidatedChangesMap.set(recordId, {
tableId: tableId,
recordId: recordId,
changeType: 'created',
changedFields: recordData.cellValuesByFieldId || {},
})
}
}
}
// Handle updated records
if (tableChanges.changedRecordsById) {
for (const [recordId, recordDataUntyped] of Object.entries(
tableChanges.changedRecordsById
)) {
const recordData = recordDataUntyped as any // Assert type
const existingChange = consolidatedChangesMap.get(recordId)
const currentFields = recordData.current?.cellValuesByFieldId || {}
if (existingChange) {
// Existing record was updated again
existingChange.changedFields = {
...existingChange.changedFields,
...currentFields,
}
// Ensure type is 'updated' if it was previously 'created'
existingChange.changeType = 'updated'
// Do not update previousFields again
} else {
// First update for this record in the batch
const newChange: AirtableChange = {
tableId: tableId,
recordId: recordId,
changeType: 'updated',
changedFields: currentFields,
}
if (recordData.previous?.cellValuesByFieldId) {
newChange.previousFields = recordData.previous.cellValuesByFieldId
}
consolidatedChangesMap.set(recordId, newChange)
}
}
}
// TODO: Handle deleted records (`destroyedRecordIds`) if needed
}
}
}
}
const nextCursor = responseBody.cursor
mightHaveMore = responseBody.mightHaveMore || false
if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) {
currentCursor = nextCursor
// --- Add logging before and after DB update ---
const updatedConfig = { ...localProviderConfig, externalWebhookCursor: currentCursor }
try {
// Force a complete object update to ensure consistency in serverless env
await db
.update(webhook)
.set({
providerConfig: updatedConfig, // Use full object
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookData.id))
localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too
} catch (dbError: any) {
logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, {
webhookId: webhookData.id,
cursor: currentCursor,
error: dbError.message,
})
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-cursor-persist-error`,
dbError,
'webhook'
)
mightHaveMore = false
throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly
}
} else if (!nextCursor || typeof nextCursor !== 'number') {
logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, {
webhookId: webhookData.id,
apiCall: apiCallCount,
receivedCursor: nextCursor,
})
mightHaveMore = false
} else {
mightHaveMore = false // Explicitly stop if cursor hasn't changed
}
} catch (fetchError: any) {
logger.error(
`[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`,
fetchError
)
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-fetch-error-${apiCallCount}`,
fetchError,
'webhook'
)
mightHaveMore = false
break
}
}
// --- End Polling Loop ---
// Convert map values to array for final processing
const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values())
// --- Execute Workflow if we have changes (simplified - no lock check) ---
if (finalConsolidatedChanges.length > 0) {
try {
// Format the input for the executor using the consolidated changes
const input = { airtableChanges: finalConsolidatedChanges } // Use the consolidated array
// Execute using the original requestId as the executionId
await executeWorkflowFromPayload(workflowData, input, requestId, requestId)
} catch (executionError: any) {
// Errors logged within executeWorkflowFromPayload
logger.error(
`[${requestId}] Error during workflow execution triggered by Airtable polling`,
executionError
)
}
}
} catch (error) {
// Catch any unexpected errors during the setup/polling logic itself
logger.error(
`[${requestId}] Unexpected error during asynchronous Airtable payload processing task`,
{
webhookId: webhookData.id,
workflowId: workflowData.id,
error: (error as Error).message,
stack: (error as Error).stack,
}
)
// Persist this higher-level error
await persistExecutionError(
workflowData.id,
`${internalPollIdPrefix}-processing-error`,
error as Error,
'webhook'
)
}
}
/**
* Process webhook verification and authorization
*/
export async function processWebhook(
foundWebhook: any,
foundWorkflow: any,
body: any,
request: NextRequest,
executionId: string,
requestId: string
): Promise<NextResponse> {
try {
// --- Provider-specific Auth/Verification (excluding Airtable/WhatsApp/Slack handled earlier) ---
if (
foundWebhook.provider &&
!['airtable', 'whatsapp', 'slack'].includes(foundWebhook.provider)
) {
const verificationResponse = verifyProviderWebhook(foundWebhook, request, requestId)
if (verificationResponse) {
return verificationResponse
}
}
// --- Format Input based on provider (excluding Airtable) ---
const input = formatWebhookInput(foundWebhook, foundWorkflow, body, request)
if (!input && foundWebhook.provider === 'whatsapp') {
return new NextResponse('No messages in WhatsApp payload', { status: 200 })
}
// --- Execute Workflow ---
logger.info(
`[${requestId}] Executing workflow ${foundWorkflow.id} for webhook ${foundWebhook.id} (Execution: ${executionId})`
)
// Call the refactored execution function
await executeWorkflowFromPayload(foundWorkflow, input, executionId, requestId)
// Since executeWorkflowFromPayload handles logging and errors internally,
// we just need to return a standard success response for synchronous webhooks.
// Note: The actual result isn't typically returned in the webhook response itself.
return NextResponse.json({ message: 'Webhook processed' }, { status: 200 })
} catch (error: any) {
// Catch errors *before* calling executeWorkflowFromPayload (e.g., auth errors)
logger.error(
`[${requestId}] Error in processWebhook *before* execution for ${foundWebhook.id} (Execution: ${executionId})`,
error
)
return new NextResponse(`Internal Server Error: ${error.message}`, {
status: 500,
})
}
}
/**
* Generate a hash for request deduplication
*/
export async function generateRequestHash(path: string, body: any): Promise<string> {
try {
const normalizedBody = normalizeBody(body)
const requestString = `${path}:${JSON.stringify(normalizedBody)}`
let hash = 0
for (let i = 0; i < requestString.length; i++) {
const char = requestString.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return `request:${path}:${hash}`
} catch (error) {
return `request:${path}:${uuidv4()}`
}
}
/**
* Normalize the body for consistent hashing
*/
export function normalizeBody(body: any): any {
if (!body || typeof body !== 'object') return body
const result = Array.isArray(body) ? [...body] : { ...body }
const fieldsToRemove = [
'timestamp',
'random',
'nonce',
'requestId',
'event_id',
'event_time' /* Add other volatile fields */,
] // Made case-insensitive check below
if (Array.isArray(result)) {
return result.map((item) => normalizeBody(item))
} else {
for (const key in result) {
// Use lowercase check for broader matching
if (fieldsToRemove.includes(key.toLowerCase())) {
delete result[key]
} else if (typeof result[key] === 'object' && result[key] !== null) {
result[key] = normalizeBody(result[key])
}
}
return result
}
}
// Define an interface for AirtableChange
export interface AirtableChange {
tableId: string
recordId: string
changeType: 'created' | 'updated'
changedFields: Record<string, any> // { fieldId: newValue }
previousFields?: Record<string, any> // { fieldId: previousValue } (optional)
}
+15
View File
@@ -21,6 +21,7 @@ const initialState = {
deployedAt: undefined,
needsRedeployment: false,
hasActiveSchedule: false,
hasActiveWebhook: false,
history: {
past: [],
present: {
@@ -323,6 +324,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
isDeployed: false,
isPublished: false,
hasActiveSchedule: false,
hasActiveWebhook: false,
}
set(newState)
workflowSync.sync()
@@ -654,6 +656,19 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
get().updateLastSaved()
}
},
setWebhookStatus: (hasActiveWebhook: boolean) => {
// Only update if the status has changed to avoid unnecessary rerenders
if (get().hasActiveWebhook !== hasActiveWebhook) {
// If the workflow has an active schedule, disable it
if (get().hasActiveSchedule) {
get().setScheduleStatus(false)
}
set({ hasActiveWebhook })
get().updateLastSaved()
}
},
})),
{ name: 'workflow-store' }
)
+2
View File
@@ -43,6 +43,7 @@ export interface WorkflowState {
deployedAt?: Date
needsRedeployment?: boolean
hasActiveSchedule?: boolean
hasActiveWebhook?: boolean
}
export interface WorkflowActions {
@@ -66,6 +67,7 @@ export interface WorkflowActions {
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
setScheduleStatus: (hasActiveSchedule: boolean) => void
setWebhookStatus: (hasActiveWebhook: boolean) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions