Files
sim/apps/sim/app/api/webhooks/[id]/route.ts
Waleed ace87791d8 feat(analytics): add PostHog product analytics (#3910)
* feat(analytics): add PostHog product analytics

* fix(posthog): fix workspace group via URL params, type errors, and clean up comments

* fix(posthog): address PR review - fix pre-tx event, auth_method, paused executions, enterprise cancellation, settings double-fire

* chore(posthog): remove unused identifyServerPerson

* fix(posthog): isolate processQueuedResumes errors, simplify settings posthog deps

* fix(posthog): correctly classify SSO auth_method, fix phantom empty-string workspace groups

* fix(posthog): remove usePostHog from memo'd TemplateCard, fix copilot chat phantom workspace group

* fix(posthog): eliminate all remaining phantom empty-string workspace groups

* fix(posthog): fix cancel route phantom group, remove redundant workspaceId shadow in catch block

* fix(posthog): use ids.length for block_removed guard to handle container blocks with descendants

* chore(posthog): remove unused removedBlockTypes variable

* fix(posthog): remove phantom $set person properties from subscription events

* fix(posthog): add passedKnowledgeBaseName to knowledge_base_opened effect deps

* fix(posthog): capture currentWorkflowId synchronously before async import to avoid stale closure

* fix(posthog): add typed captureEvent wrapper for React components, deduplicate copilot_panel_opened

* feat(posthog): add task_created and task_message_sent events, remove copilot_panel_opened

* feat(posthog): track task_renamed, task_deleted, task_marked_read, task_marked_unread

* feat(analytics): expand posthog event coverage with source tracking and lifecycle events

* fix(analytics): flush posthog events on SIGTERM before ECS task termination

* fix(analytics): fix posthog in useCallback deps and fire block events for bulk operations
2026-04-03 01:00:35 -07:00

300 lines
9.6 KiB
TypeScript

import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
const logger = createLogger('WebhookAPI')
export const dynamic = 'force-dynamic'
// Get a specific webhook
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
try {
const { id } = await params
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized webhook access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.id, id), isNull(webhook.archivedAt)))
.limit(1)
if (webhooks.length === 0) {
logger.warn(`[${requestId}] Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
const webhookData = webhooks[0]
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: webhookData.workflow.id,
userId,
action: 'read',
})
const hasAccess = authorization.allowed
if (!hasAccess) {
logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
logger.info(`[${requestId}] Successfully retrieved webhook: ${id}`)
return NextResponse.json({ webhook: webhooks[0] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching webhook`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
try {
const { id } = await params
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized webhook update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const body = await request.json()
const { isActive, failedCount } = body
if (failedCount !== undefined) {
const validation = validateInteger(failedCount, 'failedCount', { min: 0 })
if (!validation.isValid) {
logger.warn(`[${requestId}] ${validation.error}`)
return NextResponse.json({ error: validation.error }, { status: 400 })
}
}
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.id, id), isNull(webhook.archivedAt)))
.limit(1)
if (webhooks.length === 0) {
logger.warn(`[${requestId}] Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
const webhookData = webhooks[0]
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: webhookData.workflow.id,
userId,
action: 'write',
})
const canModify = authorization.allowed
if (!canModify) {
logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const updatedWebhook = await db
.update(webhook)
.set({
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
updatedAt: new Date(),
})
.where(eq(webhook.id, id))
.returning()
logger.info(`[${requestId}] Successfully updated webhook: ${id}`)
return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error updating webhook`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Delete a webhook
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
try {
const { id } = await params
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
// Find the webhook and check permissions
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, id))
.limit(1)
if (webhooks.length === 0) {
logger.warn(`[${requestId}] Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
const webhookData = webhooks[0]
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: webhookData.workflow.id,
userId,
action: 'write',
})
const canDelete = authorization.allowed
if (!canDelete) {
logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const foundWebhook = webhookData.webhook
const credentialSetId = foundWebhook.credentialSetId as string | undefined
const blockId = foundWebhook.blockId as string | undefined
if (credentialSetId && blockId) {
const allCredentialSetWebhooks = await db
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, webhookData.workflow.id),
eq(webhook.blockId, blockId),
isNull(webhook.archivedAt)
)
)
const webhooksToDelete = allCredentialSetWebhooks.filter(
(w) => w.credentialSetId === credentialSetId
)
for (const w of webhooksToDelete) {
await cleanupExternalWebhook(w, webhookData.workflow, requestId)
}
const idsToDelete = webhooksToDelete.map((w) => w.id)
for (const wId of idsToDelete) {
await db.delete(webhook).where(eq(webhook.id, wId))
}
try {
for (const wId of idsToDelete) {
PlatformEvents.webhookDeleted({
webhookId: wId,
workflowId: webhookData.workflow.id,
})
}
} catch {
// Telemetry should not fail the operation
}
logger.info(
`[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`,
{
credentialSetId,
blockId,
deletedIds: idsToDelete,
}
)
} else {
await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
await db.delete(webhook).where(eq(webhook.id, id))
try {
PlatformEvents.webhookDeleted({
webhookId: id,
workflowId: webhookData.workflow.id,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
}
recordAudit({
workspaceId: webhookData.workflow.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WEBHOOK_DELETED,
resourceType: AuditResourceType.WEBHOOK,
resourceId: id,
resourceName: foundWebhook.provider || 'generic',
description: 'Deleted webhook',
metadata: { workflowId: webhookData.workflow.id },
request,
})
const wsId = webhookData.workflow.workspaceId || undefined
captureServerEvent(
userId,
'webhook_trigger_deleted',
{
webhook_id: id,
workflow_id: webhookData.workflow.id,
provider: foundWebhook.provider || 'generic',
workspace_id: wsId ?? '',
},
wsId ? { groups: { workspace: wsId } } : undefined
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting webhook`, {
error: error.message,
stack: error.stack,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}