mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-13 07:38:33 +08:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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}
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+5
-1
@@ -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>
|
||||
))}
|
||||
|
||||
+8
-12
@@ -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>
|
||||
)}
|
||||
|
||||
+17
-2
@@ -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 *"
|
||||
|
||||
-11
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+79
-38
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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>
|
||||
)
|
||||
|
||||
+10
-3
@@ -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" />}
|
||||
|
||||
+3
-12
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+105
@@ -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>
|
||||
)
|
||||
}
|
||||
+48
-58
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
-53
@@ -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>
|
||||
)
|
||||
}
|
||||
+29
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+58
-25
@@ -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>
|
||||
|
||||
|
||||
+74
-21
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user