Skip to content

Commit a1d8c79

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(tiktok): preserve webhook delivery guarantees
1 parent 9096ca7 commit a1d8c79

4 files changed

Lines changed: 60 additions & 8 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('TikTok app webhook route', () => {
145145
expect(order).toEqual(['start:webhook-1', 'end:webhook-1', 'start:webhook-2', 'end:webhook-2'])
146146
})
147147

148-
it('acknowledges provider-local dispatch failures like the Slack app route', async () => {
148+
it('returns a retryable response when a target cannot be dispatched', async () => {
149149
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1')])
150150
mockDispatchResolvedWebhookTarget.mockResolvedValue({
151151
outcome: 'failed',
@@ -154,7 +154,7 @@ describe('TikTok app webhook route', () => {
154154

155155
const response = await POST(signedRequest())
156156

157-
expect(response.status).toBe(200)
157+
expect(response.status).toBe(503)
158158
})
159159

160160
it('returns 503 when target lookup fails', async () => {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9898
const legacyWebhooks = await findLegacyTikTokWebhooks(envelope.user_openid)
9999
const webhooks = [...routedWebhooks, ...legacyWebhooks]
100100
let dispatched = 0
101+
let failed = 0
101102
for (const { webhook, workflow } of webhooks) {
102103
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
103104
requestId,
104105
receivedAt,
105106
triggerTimestampMs: envelope.create_time * 1000,
106107
})
107108
if (result.outcome === 'queued') dispatched += 1
109+
if (result.outcome === 'failed') failed += 1
108110
}
109111

110112
logger.info(`[${requestId}] Processed TikTok webhook delivery`, {
111113
dispatched,
114+
failed,
112115
event: envelope.event,
113116
targetCount: webhooks.length,
114117
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
115118
})
116119

120+
if (failed > 0) {
121+
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
122+
}
123+
117124
return NextResponse.json({ ok: true })
118125
} catch (error) {
119126
logger.error(`[${requestId}] TikTok webhook processing error`, {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import {
6+
dbChainMock,
7+
dbChainMockFns,
8+
drizzleOrmMock,
9+
resetDbChainMock,
10+
schemaMock,
11+
} from '@sim/testing'
12+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
15+
vi.mock('@sim/db/schema', () => schemaMock)
16+
vi.mock('drizzle-orm', () => drizzleOrmMock)
17+
18+
import { clearCredentialRefs } from '@/lib/credentials/deletion'
19+
20+
describe('credential-bound webhook deactivation', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
resetDbChainMock()
24+
})
25+
26+
afterAll(() => {
27+
resetDbChainMock()
28+
})
29+
30+
it('deactivates TikTok and Slack webhook registrations when a credential is removed', async () => {
31+
await clearCredentialRefs('credential-1', 'workspace-1')
32+
33+
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.webhook)
34+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
35+
expect.objectContaining({ isActive: false, updatedAt: expect.any(Date) })
36+
)
37+
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'tiktok')
38+
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack_app')
39+
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack')
40+
})
41+
})

apps/sim/lib/credentials/deletion.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,18 @@ export async function clearCredentialRefs(
9090
clearInPausedExecutions(credentialId, workspaceId, needle),
9191
clearInWorkflowCheckpoints(credentialId, workspaceId, needle),
9292
clearInKnowledgeConnectors(credentialId),
93-
deactivateSlackAppWebhooks(credentialId),
93+
deactivateCredentialBoundWebhooks(credentialId),
9494
])
9595
}
9696

9797
/**
98-
* Deactivates Slack trigger webhooks bound to this credential so inbound
99-
* events stop routing once the account is disconnected: native (`slack_app`)
100-
* rows reference it via `providerConfig.credentialId`, custom-bot (`slack`)
101-
* rows via `routingKey` = the bot credential id. Neither is a foreign key, so
98+
* Deactivates app-level trigger webhooks bound to this credential so inbound
99+
* events stop routing once the account is disconnected. Native Slack and
100+
* TikTok rows reference it via `providerConfig.credentialId`; custom-bot Slack
101+
* rows use `routingKey` = the bot credential id. Neither is a foreign key, so
102102
* neither is covered by CASCADE.
103103
*/
104-
async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
104+
async function deactivateCredentialBoundWebhooks(credentialId: string): Promise<void> {
105105
await db
106106
.update(schema.webhook)
107107
.set({ isActive: false, updatedAt: new Date() })
@@ -113,6 +113,10 @@ async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
113113
eq(schema.webhook.provider, 'slack_app'),
114114
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
115115
),
116+
and(
117+
eq(schema.webhook.provider, 'tiktok'),
118+
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
119+
),
116120
and(eq(schema.webhook.provider, 'slack'), eq(schema.webhook.routingKey, credentialId))
117121
)
118122
)

0 commit comments

Comments
 (0)