Skip to content

Commit 0eca254

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(tiktok): route webhooks by account key
1 parent 3d8b2ed commit 0eca254

8 files changed

Lines changed: 18636 additions & 45 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('acknowledges provider-local dispatch failures like the Slack app route', 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(200)
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: 16 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,27 @@ 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+
for (const { webhook, workflow } of webhooks) {
99+
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
100+
requestId,
101+
receivedAt,
102+
triggerTimestampMs: envelope.create_time * 1000,
103+
})
104+
if (result.outcome === 'queued') dispatched += 1
106105
}
107-
const jobId = await enqueueTikTokWebhookIngress(payload)
108106

109-
logger.info(`[${requestId}] Accepted TikTok webhook delivery`, {
107+
logger.info(`[${requestId}] Processed TikTok webhook delivery`, {
108+
dispatched,
110109
event: envelope.event,
111-
jobId,
110+
targetCount: webhooks.length,
112111
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
113112
})
114113

115114
return NextResponse.json({ ok: true })
116115
} catch (error) {
117-
logger.error(`[${requestId}] TikTok webhook ingress error`, {
116+
logger.error(`[${requestId}] TikTok webhook processing error`, {
118117
error: getErrorMessage(error, 'Unknown error'),
119118
})
120119
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })

apps/sim/lib/webhooks/deploy.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ const slackTrigger = trigger([
9292
},
9393
])
9494

95+
const tiktokTrigger = trigger([
96+
{
97+
id: 'triggerCredentials',
98+
mode: 'trigger',
99+
serviceId: 'tiktok',
100+
required: true,
101+
},
102+
])
103+
95104
function makeBlock(
96105
type: string,
97106
subBlockValues: Record<string, unknown>,
@@ -359,3 +368,63 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
359368
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
360369
})
361370
})
371+
372+
describe('resolveWebhookConfigForBlock — TikTok routing', () => {
373+
const tiktokTriggerDef = {
374+
provider: 'tiktok',
375+
name: 'TikTok',
376+
subBlocks: tiktokTrigger.subBlocks,
377+
}
378+
379+
function resolveTikTok(
380+
credentialReference = 'credential-1',
381+
workflow: Record<string, unknown> = { workspaceId: 'ws-1' }
382+
) {
383+
;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' })
384+
;(getTrigger as unknown as Mock).mockReturnValue(tiktokTriggerDef)
385+
return resolveWebhookConfigForBlock({
386+
block: makeBlock('tiktok', { triggerCredentials: credentialReference }),
387+
workflow,
388+
userId: 'deployer-1',
389+
requestId: 'req-1',
390+
})
391+
}
392+
393+
it('routes a canonical workspace credential by its TikTok open_id', async () => {
394+
queueTableRows(credential, [{ id: 'credential-1' }])
395+
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'account-1' })
396+
queueTableRows(account, [
397+
{ accountId: 'open-id-with-hyphens-12345678-1234-1234-1234-123456789abc' },
398+
])
399+
400+
const result = await resolveTikTok()
401+
402+
expect(result?.success).toBe(true)
403+
if (!result?.success) throw new Error('expected success')
404+
expect(result.config.provider).toBe('tiktok')
405+
expect(result.config.routingKey).toBe('open-id-with-hyphens')
406+
expect(result.config.triggerPath).toBeNull()
407+
expect(result.config.providerConfig.credentialId).toBe('credential-1')
408+
})
409+
410+
it('rejects a TikTok credential not available in the workflow workspace', async () => {
411+
const result = await resolveTikTok('foreign-credential')
412+
413+
expect(result?.success).toBe(false)
414+
if (result?.success) throw new Error('expected failure')
415+
expect(result?.error.message).toContain('not available in this workspace')
416+
expect(mockResolveOAuthAccountId).not.toHaveBeenCalled()
417+
})
418+
419+
it('rejects a malformed TikTok account identity', async () => {
420+
queueTableRows(credential, [{ id: 'credential-1' }])
421+
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'account-1' })
422+
queueTableRows(account, [{ accountId: 'missing-generated-uuid' }])
423+
424+
const result = await resolveTikTok()
425+
426+
expect(result?.success).toBe(false)
427+
if (result?.success) throw new Error('expected failure')
428+
expect(result?.error.message).toContain('Reconnect')
429+
})
430+
})

apps/sim/lib/webhooks/deploy.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
4040
import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared'
4141

4242
const logger = createLogger('DeployWebhookSync')
43+
const TIKTOK_ACCOUNT_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
4344

4445
interface TriggerSaveError {
4546
message: string
@@ -362,9 +363,8 @@ export async function resolveTriggerCredentialId(
362363
}
363364

364365
/**
365-
* Resolves a trigger block to its persisted webhook config, including the
366-
* Slack-specific routing branch. Exported for unit testing that branch; not part
367-
* of the public deploy API.
366+
* Resolves a trigger block to its persisted webhook config, including app-level
367+
* provider routing. Exported for focused unit testing; not part of the public deploy API.
368368
*/
369369
export async function resolveWebhookConfigForBlock(input: {
370370
block: BlockState
@@ -565,6 +565,36 @@ export async function resolveWebhookConfigForBlock(input: {
565565
// (`slack_app`) rows on providerConfig.credentialId.
566566
providerConfig.credentialId = resolvedCredentialId
567567
}
568+
} else if (triggerDef.provider === 'tiktok') {
569+
if (!credentialId) {
570+
return {
571+
success: false,
572+
error: { message: 'Select a TikTok account for the trigger.', status: 400 },
573+
}
574+
}
575+
576+
const resolvedAccount = await resolveOAuthAccountId(credentialId)
577+
const [tiktokAccount] = resolvedAccount?.accountId
578+
? await db
579+
.select({ accountId: account.accountId })
580+
.from(account)
581+
.where(and(eq(account.id, resolvedAccount.accountId), eq(account.providerId, 'tiktok')))
582+
.limit(1)
583+
: []
584+
const openId = tiktokAccount?.accountId.replace(TIKTOK_ACCOUNT_UUID_SUFFIX, '')
585+
586+
if (!openId || openId === tiktokAccount?.accountId) {
587+
return {
588+
success: false,
589+
error: {
590+
message: 'Could not verify the connected TikTok account. Reconnect it and try again.',
591+
status: 400,
592+
},
593+
}
594+
}
595+
596+
effectivePath = null
597+
routingKey = openId
568598
}
569599

570600
return {

0 commit comments

Comments
 (0)