Skip to content

Commit b022e93

Browse files
feat(inbox): record disconnect reason and add reconcile safety limits (#1115)
* feat(inbox): record disconnect reason and add reconcile safety limits Adds `disconnectedAt`/`disconnectReason` columns to Inbox so a channel disconnect (manual, workspace purge, trial expiry, tenant suspension) has a forensic trail on every edition, not just where audit logging is enabled. Threads the reason through inboxService, workspaceLifecycleService, and every disconnect call site (channel actions, workspace purge, tenant suspend, trial teardown). Also adds circuit breakers to reconcileTenants and unsubscribeExpiredTrials that abort the destructive branch when a batch looks abnormally large, since that pattern is far more consistent with a quota-worker bug than real churn. * fix(inbox): drop reconcile circuit breakers and duplicate audit dispatch The trial-teardown breaker returned before re-enqueueing its scan cursor, so it stalled that job permanently once a batch tripped it; the reconcile breaker's own tests never actually exercised the threshold and were red on HEAD. Both are removed rather than retuned. The disconnect audit call in inboxService duplicated each caller's own richer audit entry and clobbered `source` with `withAuditContext`'s value, so it's deleted in favor of the callers' records and the disconnectReason column. Reconnect now clears disconnectedAt/disconnectReason, workspace teardown resolves `token_revoked` from provider errors instead of leaving it dead, and the routine per-inbox teardown log is demoted from error to info.
1 parent e3303c7 commit b022e93

28 files changed

Lines changed: 43299 additions & 9 deletions

File tree

apps/builder/__tests__/disconnect-meta-actions.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ describe("Meta disconnect actions", () => {
192192
inboxId: "inbox-1",
193193
ownerId: "owner-1",
194194
workspaceId: "workspace-1",
195+
reason: "manual",
195196
tx: mocks.tx,
196197
})
197198
})
@@ -227,6 +228,7 @@ describe("Meta disconnect actions", () => {
227228
inboxId: "inbox-2",
228229
ownerId: "owner-1",
229230
workspaceId: "workspace-1",
231+
reason: "manual",
230232
tx: mocks.tx,
231233
})
232234
})
@@ -275,6 +277,7 @@ describe("Meta disconnect actions", () => {
275277
inboxId: "inbox-1",
276278
ownerId: "owner-1",
277279
workspaceId: "workspace-1",
280+
reason: "manual",
278281
tx: mocks.tx,
279282
})
280283
expect(mocks.auditRecord).toHaveBeenCalled()

apps/builder/__tests__/disconnect-whatsapp-action.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe("disconnectWhatsappAction", () => {
127127
inboxId: "inbox-1",
128128
ownerId: "owner-1",
129129
workspaceId: "workspace-1",
130+
reason: "manual",
130131
tx: mocks.tx,
131132
})
132133
})

apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const disconnectInstagram = async (ctx: {
9999
inboxId: integrationInstagram.inboxId,
100100
ownerId: workspace.ownerId,
101101
workspaceId: ctx.workspaceId,
102+
reason: "manual",
102103
tx,
103104
})
104105
})

apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const disconnectMessenger = async (ctx: {
119119
inboxId: integrationMessenger.inboxId,
120120
ownerId: workspace.ownerId,
121121
workspaceId: ctx.workspaceId,
122+
reason: "manual",
122123
tx,
123124
})
124125
})

apps/builder/src/features/integration-smtp/services/smtp.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export async function deleteSmtp(workspaceId: string, id: string) {
185185
inboxId: integration.inboxId,
186186
ownerId: workspace.ownerId,
187187
workspaceId,
188+
reason: "manual",
188189
tx,
189190
})
190191
})

apps/builder/src/features/integration-telegram/actions/disconnect.action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired
4949
inboxId: integrationTelegram.inboxId,
5050
ownerId: workspace.ownerId,
5151
workspaceId,
52+
reason: "manual",
5253
tx,
5354
})
5455
})

apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const disconnectTiktokAction = workspaceActionClientAllowExpired
3535
inboxId: integrationTiktok.inboxId,
3636
ownerId: workspace.ownerId,
3737
workspaceId,
38+
reason: "manual",
3839
tx,
3940
})
4041
})

apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async function purgeWhatsappIntegration(
9090
inboxId: integrationWhatsapp.inboxId,
9191
ownerId,
9292
workspaceId,
93+
reason: "manual",
9394
tx,
9495
})
9596
}

apps/builder/src/features/integration-zalo/actions/disconnect.action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired
6565
inboxId: integrationZalo.inboxId,
6666
ownerId: workspace.ownerId,
6767
workspaceId,
68+
reason: "manual",
6869
tx,
6970
})
7071
})

apps/worker/__tests__/purge-workspaces.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ vi.mock("@chatbotx.io/logger", () => ({
2121
vi.mock("../src/services/integrations", () => ({
2222
allIntegrations: ["integration"],
2323
}))
24+
// Real AsyncLocalStorage context, isolated from the audit dispatcher's
25+
// Snowflake id generator — this handler only needs the wrapped call to run.
26+
vi.mock("@chatbotx.io/business/audit", async () => {
27+
const { AsyncLocalStorage } = await import("node:async_hooks")
28+
const storage = new AsyncLocalStorage<Record<string, unknown>>()
29+
return {
30+
SYSTEM_ACTOR: "system",
31+
withAuditContext: (actor: Record<string, unknown>, fn: () => unknown) =>
32+
storage.run(actor, fn),
33+
getAuditActor: () => storage.getStore(),
34+
}
35+
})
2436

2537
const { purgeWorkspaces } = await import(
2638
"../src/schedule/handlers/purge-workspaces"

0 commit comments

Comments
 (0)