Skip to content

Commit fb6e18f

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(tiktok): align webhook routing with shared dispatcher (#6261)
* refactor(tiktok): route webhooks by account key * refactor(tiktok): remove custom webhook ingress jobs * fix(tiktok): constrain webhook routing migration * test(tiktok): remove redundant assertions * fix(tiktok): preserve routing backfill state * fix(tiktok): preserve legacy webhook routing during rollout * fix(tiktok): preserve webhook delivery guarantees * refactor(tiktok): remove rollout compatibility * refactor(tiktok): remove unused webhook backfill --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 9883543 commit fb6e18f

19 files changed

Lines changed: 19056 additions & 743 deletions

File tree

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

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ 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 { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({
11-
mockEnqueueTikTokWebhookIngress: vi.fn(),
12-
mockRelease: vi.fn(),
13-
}))
10+
const { mockDispatchResolvedWebhookTarget, mockFindWebhooksByRoutingKey, mockRelease } = vi.hoisted(
11+
() => ({
12+
mockDispatchResolvedWebhookTarget: vi.fn(),
13+
mockFindWebhooksByRoutingKey: vi.fn(),
14+
mockRelease: vi.fn(),
15+
})
16+
)
1417

15-
vi.mock('@/background/tiktok-webhook-ingress', () => ({
16-
enqueueTikTokWebhookIngress: mockEnqueueTikTokWebhookIngress,
18+
vi.mock('@/lib/webhooks/processor', () => ({
19+
dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
20+
findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
1721
}))
1822

1923
vi.mock('@/lib/core/admission/gate', () => ({
@@ -29,12 +33,17 @@ vi.mock('@/lib/core/utils/with-route-handler', () => ({
2933

3034
import { POST } from '@/app/api/webhooks/tiktok/route'
3135

32-
function signedRequest(overrides?: { clientKey?: string }): NextRequest {
36+
const target = (id: string) => ({
37+
webhook: { id, path: null, provider: 'tiktok' },
38+
workflow: { id: `workflow-${id}` },
39+
})
40+
41+
function signedRequest(overrides?: { clientKey?: string; userOpenId?: string }): NextRequest {
3342
const body = JSON.stringify({
3443
client_key: overrides?.clientKey ?? 'client-key',
3544
event: 'post.publish.complete',
3645
create_time: 1_725_000_000,
37-
user_openid: 'act.user',
46+
user_openid: overrides?.userOpenId ?? 'act.user',
3847
content: '{"publish_id":"publish-1"}',
3948
})
4049
const timestamp = String(Math.floor(Date.now() / 1000))
@@ -53,38 +62,78 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest {
5362
})
5463
}
5564

56-
describe('TikTok webhook ingress route', () => {
65+
describe('TikTok app webhook route', () => {
5766
beforeEach(() => {
5867
vi.clearAllMocks()
5968
setEnv({ TIKTOK_CLIENT_ID: 'client-key', TIKTOK_CLIENT_SECRET: 'client-secret' })
6069
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1')
61-
mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1')
70+
mockFindWebhooksByRoutingKey.mockResolvedValue([])
71+
mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', reason: 'queued' })
6272
})
6373

6474
afterAll(() => {
6575
resetEnvMock()
6676
requestUtilsMockFns.mockGenerateRequestId.mockReset()
6777
})
6878

69-
it('returns 200 only after the verified delivery is accepted by the job queue', async () => {
70-
const response = await POST(signedRequest())
79+
it('routes a verified delivery by user_openid on the TikTok provider', async () => {
80+
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1')])
81+
82+
const response = await POST(signedRequest({ userOpenId: 'user-open-id' }))
7183

7284
expect(response.status).toBe(200)
7385
await expect(response.json()).resolves.toEqual({ ok: true })
74-
expect(mockEnqueueTikTokWebhookIngress).toHaveBeenCalledWith(
86+
expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith('user-open-id', 'request-1', 'tiktok')
87+
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledWith(
88+
expect.objectContaining({ id: 'webhook-1' }),
89+
expect.objectContaining({ id: 'workflow-webhook-1' }),
90+
expect.objectContaining({ user_openid: 'user-open-id' }),
91+
expect.any(NextRequest),
7592
expect.objectContaining({
76-
envelope: expect.objectContaining({
77-
client_key: 'client-key',
78-
user_openid: 'act.user',
79-
}),
8093
requestId: 'request-1',
94+
triggerTimestampMs: 1_725_000_000_000,
8195
})
8296
)
8397
expect(mockRelease).toHaveBeenCalledOnce()
8498
})
8599

86-
it('returns 503 when durable acceptance fails so TikTok retries', async () => {
87-
mockEnqueueTikTokWebhookIngress.mockRejectedValue(new Error('queue unavailable'))
100+
it('acknowledges a verified delivery when no workflow targets match', async () => {
101+
const response = await POST(signedRequest())
102+
103+
expect(response.status).toBe(200)
104+
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
105+
})
106+
107+
it('dispatches matching workflows sequentially', async () => {
108+
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1'), target('webhook-2')])
109+
const order: string[] = []
110+
mockDispatchResolvedWebhookTarget.mockImplementation(async (webhook: { id: string }) => {
111+
order.push(`start:${webhook.id}`)
112+
await Promise.resolve()
113+
order.push(`end:${webhook.id}`)
114+
return { outcome: 'queued', reason: 'queued' }
115+
})
116+
117+
const response = await POST(signedRequest())
118+
119+
expect(response.status).toBe(200)
120+
expect(order).toEqual(['start:webhook-1', 'end:webhook-1', 'start:webhook-2', 'end:webhook-2'])
121+
})
122+
123+
it('returns a retryable response when a target cannot be dispatched', async () => {
124+
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1')])
125+
mockDispatchResolvedWebhookTarget.mockResolvedValue({
126+
outcome: 'failed',
127+
reason: 'queue-failed',
128+
})
129+
130+
const response = await POST(signedRequest())
131+
132+
expect(response.status).toBe(503)
133+
})
134+
135+
it('returns 503 when target lookup fails', async () => {
136+
mockFindWebhooksByRoutingKey.mockRejectedValue(new Error('database unavailable'))
88137

89138
const response = await POST(signedRequest())
90139

@@ -96,6 +145,6 @@ describe('TikTok webhook ingress route', () => {
96145
const response = await POST(signedRequest({ clientKey: 'other-client-key' }))
97146

98147
expect(response.status).toBe(401)
99-
expect(mockEnqueueTikTokWebhookIngress).not.toHaveBeenCalled()
148+
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
100149
})
101150
})

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,10 @@ import {
1212
} from '@/lib/core/utils/stream-limits'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
15+
import { dispatchResolvedWebhookTarget, findWebhooksByRoutingKey } from '@/lib/webhooks/processor'
1516
import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
16-
import {
17-
enqueueTikTokWebhookIngress,
18-
type TikTokWebhookIngressPayload,
19-
} from '@/background/tiktok-webhook-ingress'
2017

21-
const logger = createLogger('TikTokWebhookIngress')
18+
const logger = createLogger('TikTokAppWebhookAPI')
2219

2320
const TIKTOK_BODY_LABEL = 'TikTok webhook body'
2421

@@ -38,7 +35,7 @@ async function readTikTokBody(req: Request): Promise<string> {
3835
/**
3936
* App-level TikTok webhook Callback URL.
4037
* Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
41-
* Verifies TikTok-Signature and durably accepts the delivery before background target fanout.
38+
* Verifies TikTok-Signature and routes the delivery by TikTok `user_openid`.
4239
*/
4340
export const POST = withRouteHandler(async (request: NextRequest) => {
4441
const ticket = tryAdmit()
@@ -96,25 +93,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9693
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9794
}
9895

99-
const payload: TikTokWebhookIngressPayload = {
100-
envelope,
101-
headers: {
102-
'content-type': request.headers.get('content-type') ?? 'application/json',
103-
},
104-
requestId,
105-
receivedAt,
96+
const webhooks = await findWebhooksByRoutingKey(envelope.user_openid, requestId, 'tiktok')
97+
let dispatched = 0
98+
let failed = 0
99+
for (const { webhook, workflow } of webhooks) {
100+
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
101+
requestId,
102+
receivedAt,
103+
triggerTimestampMs: envelope.create_time * 1000,
104+
})
105+
if (result.outcome === 'queued') dispatched += 1
106+
if (result.outcome === 'failed') failed += 1
106107
}
107-
const jobId = await enqueueTikTokWebhookIngress(payload)
108108

109-
logger.info(`[${requestId}] Accepted TikTok webhook delivery`, {
109+
logger.info(`[${requestId}] Processed TikTok webhook delivery`, {
110+
dispatched,
111+
failed,
110112
event: envelope.event,
111-
jobId,
113+
targetCount: webhooks.length,
112114
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
113115
})
114116

117+
if (failed > 0) {
118+
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
119+
}
120+
115121
return NextResponse.json({ ok: true })
116122
} catch (error) {
117-
logger.error(`[${requestId}] TikTok webhook ingress error`, {
123+
logger.error(`[${requestId}] TikTok webhook processing error`, {
118124
error: getErrorMessage(error, 'Unknown error'),
119125
})
120126
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })

apps/sim/background/tiktok-webhook-ingress.test.ts

Lines changed: 0 additions & 173 deletions
This file was deleted.

0 commit comments

Comments
 (0)