Skip to content

Commit 9096ca7

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(tiktok): preserve legacy webhook routing during rollout
1 parent 76f4ae6 commit 9096ca7

7 files changed

Lines changed: 247 additions & 14 deletions

File tree

apps/sim/app/api/webhooks/tiktok/route.test.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,27 @@ import { requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing'
77
import { NextRequest } from 'next/server'
88
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const { mockDispatchResolvedWebhookTarget, mockFindWebhooksByRoutingKey, mockRelease } = vi.hoisted(
11-
() => ({
12-
mockDispatchResolvedWebhookTarget: vi.fn(),
13-
mockFindWebhooksByRoutingKey: vi.fn(),
14-
mockRelease: vi.fn(),
15-
})
16-
)
10+
const {
11+
mockDispatchResolvedWebhookTarget,
12+
mockFindLegacyTikTokWebhooks,
13+
mockFindWebhooksByRoutingKey,
14+
mockRelease,
15+
} = vi.hoisted(() => ({
16+
mockDispatchResolvedWebhookTarget: vi.fn(),
17+
mockFindLegacyTikTokWebhooks: vi.fn(),
18+
mockFindWebhooksByRoutingKey: vi.fn(),
19+
mockRelease: vi.fn(),
20+
}))
1721

1822
vi.mock('@/lib/webhooks/processor', () => ({
1923
dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
2024
findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
2125
}))
2226

27+
vi.mock('@/lib/webhooks/tiktok-legacy-routing', () => ({
28+
findLegacyTikTokWebhooks: mockFindLegacyTikTokWebhooks,
29+
}))
30+
2331
vi.mock('@/lib/core/admission/gate', () => ({
2432
admissionRejectedResponse: vi.fn(() => new Response(null, { status: 503 })),
2533
tryAdmit: vi.fn(() => ({ release: mockRelease })),
@@ -68,6 +76,7 @@ describe('TikTok app webhook route', () => {
6876
setEnv({ TIKTOK_CLIENT_ID: 'client-key', TIKTOK_CLIENT_SECRET: 'client-secret' })
6977
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1')
7078
mockFindWebhooksByRoutingKey.mockResolvedValue([])
79+
mockFindLegacyTikTokWebhooks.mockResolvedValue([])
7180
mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', reason: 'queued' })
7281
})
7382

@@ -104,6 +113,22 @@ describe('TikTok app webhook route', () => {
104113
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
105114
})
106115

116+
it('also dispatches legacy null-routing-key registrations during rolling deployment', async () => {
117+
mockFindLegacyTikTokWebhooks.mockResolvedValue([target('legacy-webhook')])
118+
119+
const response = await POST(signedRequest({ userOpenId: 'legacy-user' }))
120+
121+
expect(response.status).toBe(200)
122+
expect(mockFindLegacyTikTokWebhooks).toHaveBeenCalledWith('legacy-user')
123+
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledWith(
124+
expect.objectContaining({ id: 'legacy-webhook' }),
125+
expect.objectContaining({ id: 'workflow-legacy-webhook' }),
126+
expect.any(Object),
127+
expect.any(NextRequest),
128+
expect.any(Object)
129+
)
130+
})
131+
107132
it('dispatches matching workflows sequentially', async () => {
108133
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1'), target('webhook-2')])
109134
const order: string[] = []

apps/sim/app/api/webhooks/tiktok/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
1515
import { dispatchResolvedWebhookTarget, findWebhooksByRoutingKey } from '@/lib/webhooks/processor'
1616
import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
17+
import { findLegacyTikTokWebhooks } from '@/lib/webhooks/tiktok-legacy-routing'
1718

1819
const logger = createLogger('TikTokAppWebhookAPI')
1920

@@ -93,7 +94,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9394
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9495
}
9596

96-
const webhooks = await findWebhooksByRoutingKey(envelope.user_openid, requestId, 'tiktok')
97+
const routedWebhooks = await findWebhooksByRoutingKey(envelope.user_openid, requestId, 'tiktok')
98+
const legacyWebhooks = await findLegacyTikTokWebhooks(envelope.user_openid)
99+
const webhooks = [...routedWebhooks, ...legacyWebhooks]
97100
let dispatched = 0
98101
for (const { webhook, workflow } of webhooks) {
99102
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
6+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCredentialExpression, mockEq, mockIsNull, mockLike, tables } = vi.hoisted(() => ({
9+
mockCredentialExpression: vi.fn(() => 'webhook.credentialId'),
10+
mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })),
11+
mockIsNull: vi.fn((value: unknown) => ({ isNull: value })),
12+
mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })),
13+
tables: {
14+
account: { id: 'account.id', accountId: 'account.accountId', providerId: 'account.providerId' },
15+
credential: {
16+
id: 'credential.id',
17+
accountId: 'credential.accountId',
18+
providerId: 'credential.providerId',
19+
type: 'credential.type',
20+
workspaceId: 'credential.workspaceId',
21+
},
22+
webhook: {
23+
archivedAt: 'webhook.archivedAt',
24+
deploymentVersionId: 'webhook.deploymentVersionId',
25+
isActive: 'webhook.isActive',
26+
provider: 'webhook.provider',
27+
providerConfig: 'webhook.providerConfig',
28+
routingKey: 'webhook.routingKey',
29+
workflowId: 'webhook.workflowId',
30+
},
31+
workflow: {
32+
archivedAt: 'workflow.archivedAt',
33+
id: 'workflow.id',
34+
workspaceId: 'workflow.workspaceId',
35+
},
36+
workflowDeploymentVersion: {
37+
id: 'workflowDeploymentVersion.id',
38+
isActive: 'workflowDeploymentVersion.isActive',
39+
workflowId: 'workflowDeploymentVersion.workflowId',
40+
},
41+
},
42+
}))
43+
44+
vi.mock('@sim/db', () => ({
45+
...dbChainMock,
46+
...tables,
47+
webhookCredentialIdExpression: mockCredentialExpression,
48+
}))
49+
50+
vi.mock('drizzle-orm', () => ({
51+
and: vi.fn((...conditions: unknown[]) => conditions),
52+
eq: mockEq,
53+
isNull: mockIsNull,
54+
like: mockLike,
55+
or: vi.fn((...conditions: unknown[]) => conditions),
56+
}))
57+
58+
import { findLegacyTikTokWebhooks } from '@/lib/webhooks/tiktok-legacy-routing'
59+
60+
const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555'
61+
62+
describe('findLegacyTikTokWebhooks', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
resetDbChainMock()
66+
})
67+
68+
afterAll(() => {
69+
resetDbChainMock()
70+
})
71+
72+
it('returns only the exact legacy account match and enforces tenant routing constraints', async () => {
73+
queueTableRows(tables.account, [
74+
{
75+
accountId: `act.user-${ACCOUNT_UUID}`,
76+
webhook: { id: 'webhook-1' },
77+
workflow: { id: 'workflow-1' },
78+
},
79+
{
80+
accountId: `act.user-other-${ACCOUNT_UUID}`,
81+
webhook: { id: 'webhook-2' },
82+
workflow: { id: 'workflow-2' },
83+
},
84+
])
85+
86+
await expect(findLegacyTikTokWebhooks('act.user')).resolves.toEqual([
87+
{ webhook: { id: 'webhook-1' }, workflow: { id: 'workflow-1' } },
88+
])
89+
expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId')
90+
expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok')
91+
expect(mockIsNull).toHaveBeenCalledWith('webhook.routingKey')
92+
expect(mockLike).toHaveBeenCalledWith(
93+
'account.accountId',
94+
'act.user-________-____-____-____-____________'
95+
)
96+
})
97+
98+
it('does not query for an empty open ID', async () => {
99+
await expect(findLegacyTikTokWebhooks('')).resolves.toEqual([])
100+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
101+
})
102+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
account,
3+
credential,
4+
db,
5+
webhook,
6+
webhookCredentialIdExpression,
7+
workflow,
8+
workflowDeploymentVersion,
9+
} from '@sim/db'
10+
import { and, eq, isNull, like, or } from 'drizzle-orm'
11+
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
12+
13+
const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
14+
const ACCOUNT_ID_UUID_LIKE_SUFFIX = '________-____-____-____-____________'
15+
16+
function escapeLikePattern(value: string): string {
17+
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
18+
}
19+
20+
/**
21+
* Temporary rolling-deploy fallback for TikTok registrations written by old pods after the
22+
* routing-key backfill. Remove this after every writer stores `routingKey` and a final backfill runs.
23+
*/
24+
export async function findLegacyTikTokWebhooks(userOpenId: string) {
25+
if (!userOpenId) return []
26+
27+
const rows = await db
28+
.select({
29+
accountId: account.accountId,
30+
webhook,
31+
workflow,
32+
})
33+
.from(account)
34+
.innerJoin(
35+
credential,
36+
and(
37+
eq(credential.accountId, account.id),
38+
eq(credential.type, 'oauth'),
39+
eq(credential.providerId, 'tiktok')
40+
)
41+
)
42+
.innerJoin(
43+
webhook,
44+
and(
45+
eq(webhookCredentialIdExpression(webhook.providerConfig), credential.id),
46+
eq(webhook.provider, 'tiktok'),
47+
isNull(webhook.routingKey),
48+
deliverableWebhookPredicate(webhook)
49+
)
50+
)
51+
.innerJoin(
52+
workflow,
53+
and(
54+
eq(workflow.id, webhook.workflowId),
55+
eq(workflow.workspaceId, credential.workspaceId),
56+
isNull(workflow.archivedAt)
57+
)
58+
)
59+
.leftJoin(
60+
workflowDeploymentVersion,
61+
and(
62+
eq(workflowDeploymentVersion.workflowId, workflow.id),
63+
eq(workflowDeploymentVersion.isActive, true)
64+
)
65+
)
66+
.where(
67+
and(
68+
eq(account.providerId, 'tiktok'),
69+
like(account.accountId, `${escapeLikePattern(userOpenId)}-${ACCOUNT_ID_UUID_LIKE_SUFFIX}`),
70+
or(
71+
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
72+
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
73+
)
74+
)
75+
)
76+
77+
return rows
78+
.filter(({ accountId }) => accountId.replace(ACCOUNT_ID_UUID_SUFFIX, '') === userOpenId)
79+
.map(({ webhook: webhookRecord, workflow: workflowRecord }) => ({
80+
webhook: webhookRecord,
81+
workflow: workflowRecord,
82+
}))
83+
}

packages/db/migrations/0282_tiktok_routing_key.sql

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,3 @@ END $$;
6060
DROP TABLE IF EXISTS "_tiktok_webhook_routing_backfill";
6161
--> statement-breakpoint
6262
COMMIT;
63-
--> statement-breakpoint
64-
SET lock_timeout = 0;
65-
--> statement-breakpoint
66-
DROP INDEX CONCURRENTLY IF EXISTS "webhook_tiktok_credential_id_idx";
67-
--> statement-breakpoint
68-
SET lock_timeout = '5s';

packages/db/migrations/meta/0282_snapshot.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13618,6 +13618,22 @@
1361813618
"method": "btree",
1361913619
"with": {}
1362013620
},
13621+
"webhook_tiktok_credential_id_idx": {
13622+
"name": "webhook_tiktok_credential_id_idx",
13623+
"columns": [
13624+
{
13625+
"expression": "((\"provider_config\")::jsonb ->> 'credentialId')",
13626+
"asc": true,
13627+
"isExpression": true,
13628+
"nulls": "last"
13629+
}
13630+
],
13631+
"isUnique": false,
13632+
"where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL",
13633+
"concurrently": false,
13634+
"method": "btree",
13635+
"with": {}
13636+
},
1362113637
"idx_webhook_on_workflow_id_block_id_updated_at_desc": {
1362213638
"name": "idx_webhook_on_workflow_id_block_id_updated_at_desc",
1362313639
"columns": [

packages/db/schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,11 @@ export const jobExecutionLogs = pgTable(
833833
})
834834
)
835835

836+
/** Extracts the canonical credential ID persisted in webhook provider configuration. */
837+
export function webhookCredentialIdExpression(column: AnyPgColumn): SQL<string> {
838+
return sql<string>`((${column})::jsonb ->> 'credentialId')`
839+
}
840+
836841
export const webhook = pgTable(
837842
'webhook',
838843
{
@@ -891,6 +896,11 @@ export const webhook = pgTable(
891896
providerActiveWorkflowDeploymentIdx: index(
892897
'idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468'
893898
).on(table.provider, table.isActive, table.workflowId, table.deploymentVersionId),
899+
tiktokCredentialIdIdx: index('webhook_tiktok_credential_id_idx')
900+
.on(webhookCredentialIdExpression(table.providerConfig))
901+
.where(
902+
sql`${table.provider} = 'tiktok' AND ${table.isActive} = true AND ${table.archivedAt} IS NULL`
903+
),
894904
workflowBlockUpdatedDescIdx: index('idx_webhook_on_workflow_id_block_id_updated_at_desc').on(
895905
table.workflowId,
896906
table.blockId,

0 commit comments

Comments
 (0)