feat(webhooks): testing whatsapp webhook in prod

This commit is contained in:
Waleed Latif
2025-03-07 16:55:42 -08:00
parent a4d516056a
commit 83260d0af6
4 changed files with 414 additions and 85 deletions
+102 -2
View File
@@ -115,6 +115,106 @@ export async function POST(request: NextRequest) {
// Add GET method to handle verification for providers like WhatsApp // Add GET method to handle verification for providers like WhatsApp
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Reuse the same logic for verification try {
return POST(request) // Get the webhook path from the URL
const url = new URL(request.url)
const path = url.pathname.replace('/api/webhooks/trigger', '')
if (!path || path === '/') {
return new NextResponse('Invalid webhook path', { status: 400 })
}
// Find the webhook in the database
const webhooks = await db
.select({
webhook: webhook,
workflow: workflow,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.path, path), eq(webhook.isActive, true), eq(workflow.isDeployed, true)))
.limit(1)
if (webhooks.length === 0) {
return new NextResponse('Webhook not found', { status: 404 })
}
const { webhook: foundWebhook, workflow: foundWorkflow } = webhooks[0]
// Handle provider-specific verification and authentication
if (foundWebhook.provider) {
const authHeader = request.headers.get('authorization')
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
switch (foundWebhook.provider) {
case 'whatsapp':
// Handle WhatsApp verification
if (request.method === 'GET') {
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
if (mode === 'subscribe' && token) {
if (token === providerConfig.verificationToken) {
return new NextResponse(challenge, { status: 200 })
} else {
return new NextResponse('Verification token mismatch', { status: 403 })
}
}
}
break
case 'github':
// GitHub doesn't require verification in this implementation
break
case 'stripe':
// Stripe verification would go here if needed
break
default:
// For generic webhooks, check for a token if provided in providerConfig
if (providerConfig.token) {
const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null
if (!providedToken || providedToken !== providerConfig.token) {
return new NextResponse('Unauthorized', { status: 401 })
}
}
}
}
// Parse the request body
const body = await request.json().catch(() => ({}))
// Create execution context with the webhook payload
const executionId = nanoid()
// Format the input to match the expected BlockOutput type
const input = {
webhook: {
data: {
path,
provider: foundWebhook.provider,
providerConfig: foundWebhook.providerConfig,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
}
// Execute the workflow
if (foundWorkflow.state) {
const executor = new Executor(foundWorkflow.state as SerializedWorkflow, input)
const result = await executor.execute(foundWorkflow.id)
// Return the execution result
return NextResponse.json(result, { status: 200 })
}
return new NextResponse('Workflow state not found', { status: 500 })
} catch (error: any) {
console.error('Error processing webhook:', error)
return new NextResponse(`Internal Server Error: ${error.message}`, { status: 500 })
}
} }
@@ -37,7 +37,6 @@ export async function GET(request: NextRequest) {
const verificationToken = providerConfig.verificationToken const verificationToken = providerConfig.verificationToken
if (verificationToken && token === verificationToken) { if (verificationToken && token === verificationToken) {
console.log('WhatsApp webhook verified')
return new NextResponse(challenge, { status: 200 }) return new NextResponse(challenge, { status: 200 })
} }
} }
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { ExternalLink } from 'lucide-react' import { CheckCircle2, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { WebhookModal } from '@/components/ui/webhook-modal' import { WebhookModal } from '@/components/ui/webhook-modal'
import { useSubBlockValue } from '../hooks/use-sub-block-value' import { useSubBlockValue } from '../hooks/use-sub-block-value'
@@ -29,7 +29,9 @@ interface WebhookConfigProps {
export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConfigProps) { export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConfigProps) {
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [webhookId, setWebhookId] = useState<string | null>(null)
const params = useParams() const params = useParams()
const workflowId = params.id as string const workflowId = params.id as string
@@ -37,12 +39,35 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
const [webhookProvider] = useSubBlockValue(blockId, 'webhookProvider') const [webhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
// Generate a default path based on the workflow ID if none exists // Generate a default path based on the workflow ID if none exists
const defaultPath = `/${workflowId.substring(0, 8)}` const defaultPath = `${workflowId.substring(0, 8)}`
// Use the default path if no path is set // Use the default path if no path is set
const [webhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath') const [webhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
// Store provider-specific configuration // Store provider-specific configuration
const [providerConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig') const [providerConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig')
// Check if webhook exists in the database
useEffect(() => {
const checkWebhook = async () => {
try {
// Check if there's a webhook for this workflow with this path
const pathToCheck = webhookPath || defaultPath
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&path=${pathToCheck}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
setWebhookId(data.webhooks[0].webhook.id)
} else {
setWebhookId(null)
}
}
} catch (error) {
console.error('Error checking webhook:', error)
}
}
checkWebhook()
}, [webhookPath, workflowId, defaultPath])
const handleOpenModal = () => { const handleOpenModal = () => {
setIsModalOpen(true) setIsModalOpen(true)
setError(null) setError(null)
@@ -84,6 +109,9 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
throw new Error(errorData.error || 'Failed to save webhook') throw new Error(errorData.error || 'Failed to save webhook')
} }
const data = await response.json()
setWebhookId(data.webhook.id)
console.log('Webhook configuration saved successfully') console.log('Webhook configuration saved successfully')
return true return true
} catch (error: any) { } catch (error: any) {
@@ -95,6 +123,36 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
} }
} }
const handleDeleteWebhook = async () => {
if (!webhookId) return
try {
setIsDeleting(true)
setError(null)
const response = await fetch(`/api/webhooks/${webhookId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete webhook')
}
// Clear the webhook ID
setWebhookId(null)
console.log('Webhook deleted successfully')
return true
} catch (error: any) {
console.error('Error deleting webhook:', error)
setError(error.message || 'Failed to delete webhook')
return false
} finally {
setIsDeleting(false)
}
}
return ( return (
<div className="mt-2"> <div className="mt-2">
{error && <div className="text-sm text-red-500 mb-2">{error}</div>} {error && <div className="text-sm text-red-500 mb-2">{error}</div>}
@@ -103,10 +161,19 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
size="sm" size="sm"
className="w-full" className="w-full"
onClick={handleOpenModal} onClick={handleOpenModal}
disabled={isConnecting || isSaving} disabled={isConnecting || isSaving || isDeleting}
> >
<ExternalLink className="h-4 w-4 mr-2" /> {webhookId ? (
{isSaving ? 'Saving...' : 'View Webhook URL'} <>
<CheckCircle2 className="h-4 w-4 mr-2 text-green-500" />
{isSaving ? 'Saving...' : isDeleting ? 'Deleting...' : 'Webhook Connected'}
</>
) : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : isDeleting ? 'Deleting...' : 'Configure Webhook'}
</>
)}
</Button> </Button>
{isModalOpen && ( {isModalOpen && (
@@ -117,6 +184,8 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
webhookProvider={webhookProvider || 'generic'} webhookProvider={webhookProvider || 'generic'}
workflowId={workflowId} workflowId={workflowId}
onSave={handleSaveWebhook} onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}
/> />
)} )}
</div> </div>
+237 -76
View File
@@ -1,6 +1,17 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Check, Copy, X } from 'lucide-react' import { Check, Copy, Loader2, Trash2, X } from 'lucide-react'
import { GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons' import { GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@@ -35,6 +46,8 @@ interface WebhookModalProps {
webhookProvider: string webhookProvider: string
workflowId: string workflowId: string
onSave?: (path: string, providerConfig: ProviderConfig) => void onSave?: (path: string, providerConfig: ProviderConfig) => void
onDelete?: () => void
webhookId?: string
} }
export function WebhookModal({ export function WebhookModal({
@@ -44,14 +57,48 @@ export function WebhookModal({
webhookProvider, webhookProvider,
workflowId, workflowId,
onSave, onSave,
onDelete,
webhookId,
}: WebhookModalProps) { }: WebhookModalProps) {
const [copied, setCopied] = useState<string | null>(null) const [copied, setCopied] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const isConfigured = Boolean(webhookId)
// Provider-specific configuration state // Provider-specific configuration state
const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('') const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('')
const [githubContentType, setGithubContentType] = useState('application/json') const [githubContentType, setGithubContentType] = useState('application/json')
// Load existing configuration values
useEffect(() => {
if (webhookId) {
// If we have a webhook ID, try to fetch the existing configuration
const fetchWebhookConfig = async () => {
try {
const response = await fetch(`/api/webhooks/${webhookId}`)
if (response.ok) {
const data = await response.json()
if (data.webhook?.webhook?.providerConfig) {
const config = data.webhook.webhook.providerConfig
// Check provider type and set appropriate state
if (webhookProvider === 'whatsapp' && 'verificationToken' in config) {
setWhatsappVerificationToken(config.verificationToken)
} else if (webhookProvider === 'github' && 'contentType' in config) {
setGithubContentType(config.contentType)
}
}
}
} catch (error) {
console.error('Error fetching webhook config:', error)
}
}
fetchWebhookConfig()
}
}, [webhookId, webhookProvider])
// Format the path to ensure it starts with a slash // Format the path to ensure it starts with a slash
const formattedPath = const formattedPath =
webhookPath && webhookPath.trim() !== '' webhookPath && webhookPath.trim() !== ''
@@ -88,19 +135,35 @@ export function WebhookModal({
} }
const handleSave = async () => { const handleSave = async () => {
if (onSave) { setIsSaving(true)
setSaving(true) try {
try { // Call the onSave callback with the path and provider-specific config
if (onSave) {
const providerConfig = getProviderConfig() const providerConfig = getProviderConfig()
await onSave(webhookPath || formattedPath.substring(1), providerConfig) // Use the path without the leading slash
onClose() const pathToSave = formattedPath.startsWith('/')
} catch (error) { ? formattedPath.substring(1)
console.error('Error saving webhook configuration:', error) : formattedPath
} finally { await onSave(pathToSave, providerConfig)
setSaving(false)
} }
} else { } catch (error) {
onClose() console.error('Error saving webhook:', error)
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
if (onDelete) {
await onDelete()
setShowDeleteConfirm(false)
}
} catch (error) {
console.error('Error deleting webhook:', error)
} finally {
setIsDeleting(false)
} }
} }
@@ -140,15 +203,59 @@ export function WebhookModal({
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">Setup Instructions</h4> <h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm"> <ol className="space-y-2">
<li>Go to your Meta for Developers dashboard</li> <li className="flex items-start">
<li>Navigate to your WhatsApp app settings</li> <span className="text-gray-500 mr-2">1.</span>
<li>Under "Webhooks", click "Configure"</li> <span className="text-sm">Go to WhatsApp Business Platform dashboard</span>
<li>Enter the Webhook URL shown above</li> </li>
<li>Enter the verification token you specified above</li> <li className="flex items-start">
<li>Subscribe to the "messages" webhook field</li> <span className="text-gray-500 mr-2">2.</span>
<li>Save your changes</li> <span className="text-sm">Navigate to "Configuration" in the sidebar</span>
</li>
<li className="flex items-start">
<span className="text-gray-500 mr-2">3.</span>
<span className="text-sm">Enter the URL above as "Callback URL"</span>
</li>
<li className="flex items-start">
<span className="text-gray-500 mr-2">4.</span>
<span className="text-sm">Enter your token as "Verify token"</span>
</li>
<li className="flex items-start">
<span className="text-gray-500 mr-2">5.</span>
<span className="text-sm">
Click "Verify and save" and subscribe to "messages"
</span>
</li>
</ol> </ol>
<div className="bg-blue-50 p-3 rounded-md mt-3">
<h5 className="text-sm font-medium text-blue-800">Requirements</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-blue-500 mr-2">•</span>
<span className="text-sm text-blue-700">
URL must be publicly accessible with HTTPS
</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 mr-2">•</span>
<span className="text-sm text-blue-700">
Self-signed SSL certificates not supported
</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 mr-2">•</span>
<span className="text-sm text-blue-700">
For local testing, use ngrok to expose your server
</span>
</li>
</ul>
</div>
<div className="bg-gray-50 p-3 rounded-md mt-3">
<p className="text-sm text-gray-700 flex items-center">
<span className="text-gray-400 mr-2">💡</span>
After saving, use "Test" to verify your webhook connection.
</p>
</div>
</div> </div>
</div> </div>
) )
@@ -205,64 +312,118 @@ export function WebhookModal({
} }
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <>
<DialogContent className="sm:max-w-md"> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogHeader className="flex flex-row items-center gap-2"> <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
{getProviderIcon()} <DialogHeader className="flex flex-row items-start">
<div> <div className="mr-3">{getProviderIcon()}</div>
<DialogTitle>Webhook Configuration</DialogTitle> <div>
<DialogDescription> <DialogTitle>Webhook Configuration</DialogTitle>
Configure your{' '} <DialogDescription>Configure your WhatsApp integration</DialogDescription>
{webhookProvider === 'whatsapp' </div>
? 'WhatsApp' {webhookId && (
: webhookProvider === 'github' <Badge
? 'GitHub'
: webhookProvider === 'stripe'
? 'Stripe'
: 'webhook'}{' '}
integration
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<div className="flex items-center space-x-2">
<Input id="webhook-url" value={webhookUrl} readOnly className="flex-1" />
<Button
variant="outline" variant="outline"
size="icon" className="ml-auto bg-green-50 text-green-700 border-green-200"
onClick={() => copyToClipboard(webhookUrl, 'url')}
className="flex-shrink-0"
title="Copy URL"
> >
{copied === 'url' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} Connected
</Badge>
)}
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<div className="flex items-center space-x-2">
<Input id="webhook-url" value={webhookUrl} readOnly className="flex-1" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(webhookUrl, 'url')}
>
{copied === 'url' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
{renderProviderContent()}
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-y-0 space-y-2 gap-2 mt-4">
{webhookId && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto sm:mr-auto">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="w-full sm:w-auto"
size="sm"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="mr-2 h-3 w-3" />
Delete
</>
)}
</Button>
{webhookProvider === 'whatsapp' && (
<Button
variant="outline"
onClick={() => window.open(`/webhooks/test/${webhookId}`, '_blank')}
className="w-full sm:w-auto"
size="sm"
>
Test
</Button>
)}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={onClose} className="w-full sm:w-auto" size="sm">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="w-full sm:w-auto"
size="sm"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button> </Button>
</div> </div>
</div> </DialogFooter>
</DialogContent>
</Dialog>
{/* Provider-specific instructions and configuration */} <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<div className="space-y-2 pt-2 border-t">{renderProviderContent()}</div> <AlertDialogContent>
</div> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<DialogFooter className="sm:justify-between"> <AlertDialogDescription>
<Button variant="outline" onClick={onClose}> This will delete the webhook configuration. This action cannot be undone.
Cancel </AlertDialogDescription>
</Button> </AlertDialogHeader>
<div className="flex space-x-2"> <AlertDialogFooter>
<Button <AlertDialogCancel>Cancel</AlertDialogCancel>
variant="secondary" <AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
onClick={() => window.open(`/api/webhooks/${workflowId}/test`, '_blank')} {isDeleting ? 'Deleting...' : 'Delete'}
> </AlertDialogAction>
Test Webhook </AlertDialogFooter>
</Button> </AlertDialogContent>
<Button onClick={handleSave} disabled={saving}> </AlertDialog>
{saving ? 'Saving...' : 'Save Configuration'} </>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
) )
} }