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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10124,3 +10124,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}

export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
</svg>
)
}
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
PlaneIcon,
PlanetScaleIcon,
PolymarketIcon,
PostgresIcon,
Expand Down Expand Up @@ -558,6 +559,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
pitchbook: PitchBookIcon,
plane: PlaneIcon,
planetscale: PlanetScaleIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
"pipedrive",
"pipedrive-service-account",
"pitchbook",
"plane",
"planetscale",
"polymarket",
"postgresql",
Expand Down
5,934 changes: 5,934 additions & 0 deletions apps/docs/content/docs/integrations/plane.mdx

Large diffs are not rendered by default.

121 changes: 120 additions & 1 deletion apps/sim/app/api/webhooks/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import {
telemetryMock,
workflowAuthzMockFns,
} from '@sim/testing'
import { toRecord } from '@sim/utils/object'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
activateExternalWebhookSubscription: vi.fn(),
cleanupExternalWebhook: vi.fn(),
authorizeCredentialUseForAuth: vi.fn(),
configurePolling: vi.fn(),
createExternalWebhookSubscription: vi.fn(),
Expand All @@ -35,7 +38,8 @@ vi.mock('@/lib/webhooks/env-resolver', () => ({
resolveEnvVarsInObject: mocks.resolveEnvVarsInObject,
}))
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
cleanupExternalWebhook: vi.fn(),
activateExternalWebhookSubscription: mocks.activateExternalWebhookSubscription,
cleanupExternalWebhook: mocks.cleanupExternalWebhook,
createExternalWebhookSubscription: mocks.createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription: mocks.shouldRecreateExternalWebhookSubscription,
}))
Expand Down Expand Up @@ -462,3 +466,118 @@ describe('POST /api/webhooks credential references', () => {
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
})
})

describe('POST /api/webhooks subscription replacement recovery', () => {
beforeEach(setupUpsertMocks)
it.each([false, true])(
'retains recoverable state when prior cleanup fails: %s',
async (priorCleanupFails) => {
let cleanupUnavailable = priorCleanupFails
let activationMustFail = !priorCleanupFails
let nextExternalId = 0
let persisted: Record<string, unknown> = {
id: 'webhook-1',
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'inbound-orders',
provider: 'plane',
isActive: true,
archivedAt: null,
providerConfig: {
autoRegister: true,
projectId: 'prior',
externalId: 'external-old',
webhookSecret: 'old-secret',
},
}
const external = new Map<string, { active: boolean }>([['external-old', { active: true }]])
const desired = { autoRegister: true, projectId: 'next' }
mocks.getProviderHandler.mockReturnValue({
createSubscription: async () => undefined,
activateSubscription: async () => undefined,
})
mocks.shouldRecreateExternalWebhookSubscription.mockImplementation(
({ previousConfig, nextConfig }) => previousConfig.projectId !== nextConfig.projectId
)
const defaultSet = dbChainMockFns.set.getMockImplementation()
dbChainMockFns.set.mockImplementation((...args: unknown[]) => {
persisted = { ...persisted, ...structuredClone(toRecord(args[0])) }
return defaultSet?.(...args)
})
dbChainMockFns.returning.mockImplementation(async () => [structuredClone(persisted)])
mocks.createExternalWebhookSubscription.mockImplementation(async (_request, row) => {
const externalId = `external-${++nextExternalId}`
external.set(externalId, { active: false })
return {
updatedProviderConfig: {
...toRecord(row.providerConfig),
externalId,
webhookSecret: 'new-secret',
},
externalSubscriptionCreated: true,
}
})
mocks.activateExternalWebhookSubscription.mockImplementation(async (_request, row) => {
const resource = external.get(String(toRecord(row.providerConfig).externalId))
if (!resource) throw new Error('provider subscription missing')
if (activationMustFail) {
activationMustFail = false
throw new Error('activation unavailable')
}
resource.active = true
})
mocks.cleanupExternalWebhook.mockImplementation(
async (row, _workflow, _requestId, options) => {
const externalId = String(toRecord(row.providerConfig).externalId)
if (cleanupUnavailable && externalId === 'external-old') {
if (options?.throwOnError) throw new Error('cleanup unavailable')
return
}
external.delete(externalId)
}
)
function queueCurrentRows(): void {
queueTableRows(workflow, [
{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' },
])
queueTableRows(webhook, [{ id: 'webhook-1' }])
queueTableRows(webhook, [structuredClone(persisted)])
}
function replacementRequest() {
return createMockRequest('POST', {
workflowId: 'workflow-1',
path: 'inbound-orders',
provider: 'plane',
providerConfig: desired,
})
}
queueCurrentRows()
expect((await POST(replacementRequest())).status).toBe(500)
expect(external.size).toBe(1)
const retainedConfig = toRecord(persisted.providerConfig)
if (priorCleanupFails) {
expect(retainedConfig).toEqual({
autoRegister: true,
projectId: 'prior',
externalId: 'external-old',
webhookSecret: 'old-secret',
})
expect(external.get('external-old')?.active).toBe(true)
} else {
expect(retainedConfig.projectId).toBe('next')
expect(retainedConfig.webhookSecret).toBe('new-secret')
expect(external.has('external-old')).toBe(false)
expect(external.get(String(retainedConfig.externalId))?.active).toBe(false)
}
cleanupUnavailable = false
queueCurrentRows()
expect((await POST(replacementRequest())).status).toBe(200)
expect(external.size).toBe(1)
const recoveredConfig = toRecord(persisted.providerConfig)
expect(recoveredConfig.projectId).toBe('next')
expect(recoveredConfig.webhookSecret).toBe('new-secret')
expect(external.get(String(recoveredConfig.externalId))?.active).toBe(true)
if (!priorCleanupFails) expect(recoveredConfig.externalId).toBe(retainedConfig.externalId)
}
)
})
24 changes: 23 additions & 1 deletion apps/sim/app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { captureServerEvent } from '@/lib/posthog/server'
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import {
activateExternalWebhookSubscription,
cleanupExternalWebhook,
createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription,
Expand Down Expand Up @@ -500,8 +501,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}

const cleanupBeforeCreate = Boolean(
existingWebhook &&
shouldRecreateSubscription &&
(getProviderHandler(provider).activateSubscription ||
getProviderHandler(existingWebhook.provider).activateSubscription)
)
if (!existingWebhook || shouldRecreateSubscription) {
try {
if (cleanupBeforeCreate) {
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId, {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
throwOnError: true,
})
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
const result = await createExternalWebhookSubscription(
request,
createTempWebhookData(),
Expand Down Expand Up @@ -597,7 +609,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
throw dbError
}

if (existingWebhook && shouldRecreateSubscription) {
if (savedWebhook) {
await activateExternalWebhookSubscription(
request,
savedWebhook,
workflowRecord,
userId,
requestId
)
}

if (existingWebhook && shouldRecreateSubscription && !cleanupBeforeCreate) {
try {
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId)
} catch (cleanupError) {
Expand Down
Loading
Loading