diff --git a/apps/builder/__tests__/channel-connect-credential-consistency.test.ts b/apps/builder/__tests__/channel-connect-credential-consistency.test.ts index eb4d6df95b..6c6a2a72d7 100644 --- a/apps/builder/__tests__/channel-connect-credential-consistency.test.ts +++ b/apps/builder/__tests__/channel-connect-credential-consistency.test.ts @@ -39,11 +39,13 @@ vi.mock("@chatbotx.io/business", () => ({ platformCredentialService: { resolveForOwner: mockResolveForOwner }, workspaceService: { create: vi.fn() }, resolveTenantSettings: vi.fn(), - updateInstagramIntegrationUserInfo: vi.fn(), - updateMessengerIntegrationUserInfo: vi.fn(), + messengerIntegrationService: { updateUserInfo: vi.fn() }, + instagramIntegrationService: { updateUserInfo: vi.fn() }, tagSyncService: { enqueueChannelScan: vi.fn() }, userQuotaService: { getAccessState: vi.fn(async () => ({ blocked: false })) }, connectChannelIntegration: vi.fn(), + connectMessengerPage: vi.fn(), + connectInstagramAccount: vi.fn(), })) vi.mock("@chatbotx.io/business/errors", () => ({ diff --git a/apps/builder/__tests__/disconnect-meta-actions.test.ts b/apps/builder/__tests__/disconnect-meta-actions.test.ts index c653f521ef..7d3b4a9282 100644 --- a/apps/builder/__tests__/disconnect-meta-actions.test.ts +++ b/apps/builder/__tests__/disconnect-meta-actions.test.ts @@ -2,85 +2,40 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const txChain = { - set: vi.fn(), - where: vi.fn(), - } - txChain.set.mockReturnValue(txChain) - txChain.where.mockResolvedValue(undefined) - - const tx = { - update: vi.fn(() => txChain), - delete: vi.fn(() => txChain), - } - - return { - auditRecord: vi.fn().mockResolvedValue(undefined), - dbTransaction: vi.fn(async (callback: (tx: unknown) => Promise) => - callback(tx), - ), - coexistTearDownForIntegration: vi.fn().mockResolvedValue(undefined), - findOrFail: vi.fn(), - inboxDisconnect: vi.fn().mockResolvedValue(undefined), - instagramExists: vi.fn().mockResolvedValue(false), - metaCapiDeleteByIntegration: vi.fn().mockResolvedValue(undefined), - loggerWarn: vi.fn(), - messengerDisconnect: vi.fn().mockResolvedValue(undefined), - messengerExists: vi.fn().mockResolvedValue(false), - instagramDisconnect: vi.fn().mockResolvedValue(undefined), - instagramFacebookDisconnect: vi.fn().mockResolvedValue(undefined), - subscribePageToAppWebhook: vi.fn().mockResolvedValue(undefined), - tx, - txChain, - workspaceFindById: vi.fn(), - } -}) +const mocks = vi.hoisted(() => ({ + auditRecord: vi.fn().mockResolvedValue(undefined), + deleteMessengerIntegrationWithCleanup: vi.fn().mockResolvedValue(undefined), + deleteInstagramIntegrationWithCleanup: vi.fn().mockResolvedValue(undefined), + instagramExists: vi.fn().mockResolvedValue(false), + loggerWarn: vi.fn(), + messengerDisconnect: vi.fn().mockResolvedValue(undefined), + messengerExists: vi.fn().mockResolvedValue(false), + messengerFindByIdForWorkspace: vi.fn(), + instagramFindByIdForWorkspace: vi.fn(), + instagramDisconnect: vi.fn().mockResolvedValue(undefined), + instagramFacebookDisconnect: vi.fn().mockResolvedValue(undefined), + subscribePageToAppWebhook: vi.fn().mockResolvedValue(undefined), + workspaceFindById: vi.fn(), +})) vi.mock("@chatbotx.io/business/audit", () => ({ auditService: { record: mocks.auditRecord }, })) vi.mock("@chatbotx.io/business", () => ({ - coexistService: { - tearDownForIntegration: mocks.coexistTearDownForIntegration, - }, - inboxService: { disconnect: mocks.inboxDisconnect }, - instagramIntegrationService: { existsForPage: mocks.instagramExists }, - messengerIntegrationService: { existsForPage: mocks.messengerExists }, - workspaceService: { findById: mocks.workspaceFindById }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mocks.dbTransaction }, - and: vi.fn((...conditions: unknown[]) => ({ conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - findOrFail: mocks.findOrFail, - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), -})) - -vi.mock("@chatbotx.io/database/repositories", () => ({ - metaCapiEventRepository: { - deleteByIntegration: mocks.metaCapiDeleteByIntegration, + deleteMessengerIntegrationWithCleanup: + mocks.deleteMessengerIntegrationWithCleanup, + deleteInstagramIntegrationWithCleanup: + mocks.deleteInstagramIntegrationWithCleanup, + instagramIntegrationService: { + existsForPage: mocks.instagramExists, + findByIdForWorkspace: mocks.instagramFindByIdForWorkspace, }, -})) - -vi.mock("@chatbotx.io/database/partials", () => ({ - channelTypes: { enum: { messenger: "messenger" } }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - coexistSyncRunModel: { - finishedAt: "finishedAt", - integrationId: "integrationId", - status: "status", - }, - integrationInstagramModel: { id: "instagramId" }, - integrationMessengerModel: { id: "messengerId" }, - tagChannelModel: { - channelType: "channelType", - integrationId: "tagIntegrationId", + messengerIntegrationService: { + existsForPage: mocks.messengerExists, + findByIdForWorkspace: mocks.messengerFindByIdForWorkspace, }, + workspaceService: { findById: mocks.workspaceFindById }, })) vi.mock("@chatbotx.io/integration-messenger", () => ({ @@ -157,15 +112,14 @@ const instagramFacebookRow = { describe("Meta disconnect actions", () => { beforeEach(() => { vi.clearAllMocks() - mocks.dbTransaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => callback(mocks.tx), - ) mocks.instagramExists.mockResolvedValue(false) mocks.messengerExists.mockResolvedValue(false) mocks.messengerDisconnect.mockResolvedValue(undefined) mocks.instagramDisconnect.mockResolvedValue(undefined) mocks.instagramFacebookDisconnect.mockResolvedValue(undefined) mocks.subscribePageToAppWebhook.mockResolvedValue(undefined) + mocks.deleteMessengerIntegrationWithCleanup.mockResolvedValue(undefined) + mocks.deleteInstagramIntegrationWithCleanup.mockResolvedValue(undefined) mocks.workspaceFindById.mockResolvedValue({ id: "workspace-1", ownerId: "owner-1", @@ -173,7 +127,7 @@ describe("Meta disconnect actions", () => { }) test("messenger disconnect preserves a shared Instagram page subscription", async () => { - mocks.findOrFail.mockResolvedValueOnce(messengerRow) + mocks.messengerFindByIdForWorkspace.mockResolvedValueOnce(messengerRow) mocks.instagramExists.mockResolvedValueOnce(true) await disconnectMessenger({ workspaceId: "workspace-1", id: "messenger-1" }) @@ -185,33 +139,33 @@ describe("Meta disconnect actions", () => { version: "v99.0", subscribedFields: "general_info", }) - expect(mocks.inboxDisconnect).toHaveBeenCalledWith({ + expect(mocks.deleteMessengerIntegrationWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "messenger-1", inboxId: "inbox-1", ownerId: "owner-1", - workspaceId: "workspace-1", - tx: mocks.tx, }) }) test("messenger disconnect calls the integration when no sibling exists", async () => { - mocks.findOrFail.mockResolvedValueOnce(messengerRow) + mocks.messengerFindByIdForWorkspace.mockResolvedValueOnce(messengerRow) await disconnectMessenger({ workspaceId: "workspace-1", id: "messenger-1" }) expect(mocks.messengerDisconnect).toHaveBeenCalledWith(messengerRow.auth) expect(mocks.subscribePageToAppWebhook).not.toHaveBeenCalled() - expect(mocks.metaCapiDeleteByIntegration).toHaveBeenCalledWith( - { - workspaceId: "workspace-1", - channel: "messenger", - integrationId: "messenger-1", - }, - mocks.tx, - ) + expect(mocks.deleteMessengerIntegrationWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "messenger-1", + inboxId: "inbox-1", + ownerId: "owner-1", + }) }) test("facebook-backed Instagram disconnect skips app unsubscribe when Messenger sibling exists", async () => { - mocks.findOrFail.mockResolvedValueOnce(instagramFacebookRow) + mocks.instagramFindByIdForWorkspace.mockResolvedValueOnce( + instagramFacebookRow, + ) mocks.messengerExists.mockResolvedValueOnce(true) await disconnectInstagram({ @@ -220,16 +174,19 @@ describe("Meta disconnect actions", () => { }) expect(mocks.instagramFacebookDisconnect).not.toHaveBeenCalled() - expect(mocks.inboxDisconnect).toHaveBeenCalledWith({ + expect(mocks.deleteInstagramIntegrationWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "instagram-1", inboxId: "inbox-2", ownerId: "owner-1", - workspaceId: "workspace-1", - tx: mocks.tx, + isFacebook: true, }) }) test("facebook-backed Instagram disconnect calls app unsubscribe when no Messenger sibling exists", async () => { - mocks.findOrFail.mockResolvedValueOnce(instagramFacebookRow) + mocks.instagramFindByIdForWorkspace.mockResolvedValueOnce( + instagramFacebookRow, + ) await disconnectInstagram({ workspaceId: "workspace-1", @@ -239,14 +196,13 @@ describe("Meta disconnect actions", () => { expect(mocks.instagramFacebookDisconnect).toHaveBeenCalledWith( instagramFacebookRow.auth, ) - expect(mocks.metaCapiDeleteByIntegration).toHaveBeenCalledWith( - { - workspaceId: "workspace-1", - channel: "instagram", - integrationId: "instagram-1", - }, - mocks.tx, - ) + expect(mocks.deleteInstagramIntegrationWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "instagram-1", + inboxId: "inbox-2", + ownerId: "owner-1", + isFacebook: true, + }) expect(mocks.auditRecord).toHaveBeenCalledWith({ workspaceId: "workspace-1", action: "disconnect", @@ -254,8 +210,8 @@ describe("Meta disconnect actions", () => { }) }) - test("messenger disconnect records a disconnect audit event after the transaction resolves", async () => { - mocks.findOrFail.mockResolvedValueOnce(messengerRow) + test("messenger disconnect records a disconnect audit event after cleanup resolves", async () => { + mocks.messengerFindByIdForWorkspace.mockResolvedValueOnce(messengerRow) await disconnectMessenger({ workspaceId: "workspace-1", id: "messenger-1" }) diff --git a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts index de5850ac96..723c5f4fe9 100644 --- a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts +++ b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts @@ -2,61 +2,25 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const txChain = { - set: vi.fn(), - where: vi.fn(), - } - txChain.set.mockReturnValue(txChain) - txChain.where.mockResolvedValue(undefined) - - const tx = { - update: vi.fn(() => txChain), - delete: vi.fn(() => txChain), - } - - return { - dbTransaction: vi.fn(async (callback: (tx: unknown) => Promise) => - callback(tx), - ), - findOrFail: vi.fn(), - inboxDisconnect: vi.fn().mockResolvedValue(undefined), - isRevokedTokenError: vi.fn(() => false), - metaCapiDeleteByIntegration: vi.fn().mockResolvedValue(undefined), - tx, - txChain, - whatsappDisconnect: vi.fn().mockResolvedValue(undefined), - workspaceFindById: vi.fn(), - } -}) - -vi.mock("@chatbotx.io/business", () => ({ - inboxService: { disconnect: mocks.inboxDisconnect }, - workspaceService: { findById: mocks.workspaceFindById }, +const mocks = vi.hoisted(() => ({ + auditRecord: vi.fn().mockResolvedValue(undefined), + deleteWithCleanup: vi.fn().mockResolvedValue(undefined), + findByIdForWorkspace: vi.fn(), + isRevokedTokenError: vi.fn(() => false), + whatsappDisconnect: vi.fn().mockResolvedValue(undefined), + workspaceFindById: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions })), - db: { transaction: mocks.dbTransaction }, - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - findOrFail: mocks.findOrFail, - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), -})) - -vi.mock("@chatbotx.io/database/repositories", () => ({ - metaCapiEventRepository: { - deleteByIntegration: mocks.metaCapiDeleteByIntegration, +vi.mock("@chatbotx.io/business", () => ({ + integrationWhatsappService: { + deleteWithCleanup: mocks.deleteWithCleanup, + findByIdForWorkspace: mocks.findByIdForWorkspace, }, + workspaceService: { findById: mocks.workspaceFindById }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - coexistSyncRunModel: { - finishedAt: "finishedAt", - integrationId: "integrationId", - status: "status", - }, - integrationWhatsappModel: { id: "whatsappId" }, - whatsappCoexistStagingModel: { phoneNumberId: "phoneNumberId" }, +vi.mock("@chatbotx.io/business/audit", () => ({ + auditService: { record: mocks.auditRecord }, })) vi.mock("@chatbotx.io/integration-whatsapp", () => ({ @@ -96,37 +60,27 @@ const integrationWhatsappRow = { describe("disconnectWhatsappAction", () => { beforeEach(() => { vi.clearAllMocks() - mocks.dbTransaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => callback(mocks.tx), - ) - mocks.findOrFail.mockResolvedValue(integrationWhatsappRow) + mocks.findByIdForWorkspace.mockResolvedValue(integrationWhatsappRow) mocks.workspaceFindById.mockResolvedValue({ id: "workspace-1", ownerId: "owner-1", }) mocks.whatsappDisconnect.mockResolvedValue(undefined) mocks.isRevokedTokenError.mockReturnValue(false) + mocks.deleteWithCleanup.mockResolvedValue(undefined) }) - test("purges MetaCapiEvent rows for the whatsapp channel before deleting the integration", async () => { + test("delegates cleanup to integrationWhatsappService.deleteWithCleanup with the phoneNumberId", async () => { await (disconnectWhatsappAction as (props: unknown) => Promise)({ bindArgsParsedInputs: ["workspace-1", "whatsapp-1"], }) - expect(mocks.metaCapiDeleteByIntegration).toHaveBeenCalledWith( - { - workspaceId: "workspace-1", - channel: "whatsapp", - integrationId: "whatsapp-1", - }, - mocks.tx, - ) - expect(mocks.tx.delete).toHaveBeenCalledWith({ id: "whatsappId" }) - expect(mocks.inboxDisconnect).toHaveBeenCalledWith({ + expect(mocks.deleteWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "whatsapp-1", + phoneNumberId: "phone-1", inboxId: "inbox-1", ownerId: "owner-1", - workspaceId: "workspace-1", - tx: mocks.tx, }) }) @@ -138,13 +92,23 @@ describe("disconnectWhatsappAction", () => { bindArgsParsedInputs: ["workspace-1", "whatsapp-1"], }) - expect(mocks.metaCapiDeleteByIntegration).toHaveBeenCalledWith( - { - workspaceId: "workspace-1", - channel: "whatsapp", - integrationId: "whatsapp-1", - }, - mocks.tx, - ) + expect(mocks.deleteWithCleanup).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "whatsapp-1", + phoneNumberId: "phone-1", + inboxId: "inbox-1", + ownerId: "owner-1", + }) + }) + + test("records the workspace-less audit event after cleanup resolves", async () => { + await (disconnectWhatsappAction as (props: unknown) => Promise)({ + bindArgsParsedInputs: ["workspace-1", "whatsapp-1"], + }) + + expect(mocks.auditRecord).toHaveBeenCalledWith({ + action: "disconnect", + detail: "disconnected the WhatsApp channel (#whatsapp-1)", + }) }) }) diff --git a/apps/builder/__tests__/instagram-select-account-ordering.test.ts b/apps/builder/__tests__/instagram-select-account-ordering.test.ts new file mode 100644 index 0000000000..5105ed3b83 --- /dev/null +++ b/apps/builder/__tests__/instagram-select-account-ordering.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// Pins the recorded behavior change in select-account.action.ts (native +// Instagram login connect): the webhook subscribe and branding call used to +// run INSIDE the same DB transaction as the integration insert. Moving the +// connect's DB body into `connectInstagramAccount` (packages/business has no +// dependency on `@chatbotx.io/integration-instagram`) forces both out of the +// transaction. The webhook subscribe now runs BEFORE the DB write (same +// shape as the Messenger select-page action) so a failed subscribe still +// prevents the connect without leaving an orphaned row; branding stays +// best-effort after the write. This test pins that order. +// --------------------------------------------------------------------------- + +const { + mockResolvePlatformOwnerId, + mockResolveForOwner, + mockConnectInstagramAccount, + mockBuildContext, + mockSubscribePageToInstagramWebhook, + mockRunChannelHandler, + mockUpdateUserInfo, + mockUpdateWorkspaceLogo, + mockPersistIntegrationUserInfo, + mockAuditRecord, +} = vi.hoisted(() => ({ + mockResolvePlatformOwnerId: vi.fn(async () => "owner-1"), + mockResolveForOwner: vi.fn(), + mockConnectInstagramAccount: vi.fn(), + mockBuildContext: vi.fn(async () => ({})), + mockSubscribePageToInstagramWebhook: vi.fn(), + mockRunChannelHandler: vi.fn(async () => undefined), + mockUpdateUserInfo: vi.fn(async () => undefined), + mockUpdateWorkspaceLogo: vi.fn(async () => undefined), + mockPersistIntegrationUserInfo: vi.fn( + async (_props: { persist: (userInfo: unknown) => Promise }) => + undefined, + ), + mockAuditRecord: vi.fn(async () => undefined), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { authActionClient: chain } +}) + +vi.mock("@/lib/platform-credential-owner", () => ({ + resolvePlatformOwnerId: mockResolvePlatformOwnerId, +})) + +vi.mock("@chatbotx.io/business", () => ({ + buildContext: mockBuildContext, + connectInstagramAccount: mockConnectInstagramAccount, + instagramIntegrationService: { updateUserInfo: mockUpdateUserInfo }, + platformCredentialService: { resolveForOwner: mockResolveForOwner }, +})) + +vi.mock("@chatbotx.io/business/audit", () => ({ + auditService: { record: mockAuditRecord }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error { + code?: string + constructor(message: string, code?: string) { + super(message) + this.code = code + } + }, +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + isDatabaseError: vi.fn(() => false), +})) + +vi.mock("@chatbotx.io/integration-instagram", () => ({ + integration: { runChannelHandler: mockRunChannelHandler }, + subscribePageToInstagramWebhook: mockSubscribePageToInstagramWebhook, +})) + +vi.mock("@chatbotx.io/sdk", () => ({ + AuthType: { oauth2: "oauth2" }, +})) + +vi.mock("next/navigation", () => ({ redirect: vi.fn() })) + +vi.mock("@/features/integration-webchat/lib", () => ({ + BRANDING_TITLE: "ChatbotX", + getBrandingUrl: vi.fn(() => "https://app.example.test/branding"), +})) + +vi.mock("@/features/workspaces/actions/upload-logo", () => ({ + updateWorkspaceLogo: mockUpdateWorkspaceLogo, +})) + +vi.mock("@/lib/integration-user-info", () => ({ + persistIntegrationUserInfo: mockPersistIntegrationUserInfo, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + +const { selectAccountAction } = await import( + "../src/features/integration-instagram/actions/select-account.action" +) + +type ActionHandler = (args: { + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const call = selectAccountAction as unknown as ActionHandler + +const integrationRow = { + id: "ig-1", + workspaceId: "ws-1", + auth: {}, +} + +describe("select-account.action ordering (native Instagram login)", () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveForOwner.mockResolvedValue({ + config: { clientId: "c1", clientSecret: "s1", version: "v23.0" }, + }) + mockConnectInstagramAccount.mockResolvedValue({ + workspaceId: "ws-1", + appUrl: "https://app.example.test", + createdWorkspace: false, + integrationRow, + wasCreated: true, + }) + }) + + test("runs webhook subscribe, THEN connectInstagramAccount, THEN branding — not inside one transaction", async () => { + const callOrder: string[] = [] + mockConnectInstagramAccount.mockImplementation(() => { + callOrder.push("connectInstagramAccount") + return Promise.resolve({ + workspaceId: "ws-1", + appUrl: "https://app.example.test", + createdWorkspace: false, + integrationRow, + wasCreated: true, + }) + }) + mockSubscribePageToInstagramWebhook.mockImplementation(() => { + callOrder.push("subscribeWebhook") + return Promise.resolve(undefined) + }) + mockRunChannelHandler.mockImplementation(() => { + callOrder.push("addBranding") + return Promise.resolve(undefined) + }) + + await call({ + parsedInput: { + workspaceId: "ws-1", + igId: "ig-1", + igName: "IG Account", + igUsername: "ig_account", + pageId: "page-1", + accessToken: "token-1", + profilePictureUrl: "https://example.test/avatar.png", + }, + ctx: { user: { id: "user-1" } }, + }) + + expect(callOrder).toEqual([ + "subscribeWebhook", + "connectInstagramAccount", + "addBranding", + ]) + }) + + test("a failed webhook subscribe prevents the connect — the DB write never runs", async () => { + mockSubscribePageToInstagramWebhook.mockRejectedValueOnce( + new Error("webhook subscribe failed"), + ) + + await expect( + call({ + parsedInput: { + workspaceId: "ws-1", + igId: "ig-1", + igName: "IG Account", + igUsername: "ig_account", + pageId: "page-1", + accessToken: "token-1", + profilePictureUrl: "https://example.test/avatar.png", + }, + ctx: { user: { id: "user-1" } }, + }), + ).rejects.toThrow("Failed to connect Instagram account") + + // The subscribe runs before the DB write, so a failure leaves no + // orphaned integration/inbox row behind. + expect(mockConnectInstagramAccount).not.toHaveBeenCalled() + }) + + test("a failed branding write is best-effort and does not fail the action", async () => { + mockRunChannelHandler.mockRejectedValueOnce(new Error("branding failed")) + + const result = await call({ + parsedInput: { + workspaceId: "ws-1", + igId: "ig-1", + igName: "IG Account", + igUsername: "ig_account", + pageId: "page-1", + accessToken: "token-1", + profilePictureUrl: "https://example.test/avatar.png", + }, + ctx: { user: { id: "user-1" } }, + }) + + expect(result).toEqual({ integrationId: "ig-1", workspaceId: "ws-1" }) + }) +}) diff --git a/apps/builder/__tests__/messaging-ads-oauth-referer-roundtrip.test.ts b/apps/builder/__tests__/messaging-ads-oauth-referer-roundtrip.test.ts index 4903036b0d..368695b1aa 100644 --- a/apps/builder/__tests__/messaging-ads-oauth-referer-roundtrip.test.ts +++ b/apps/builder/__tests__/messaging-ads-oauth-referer-roundtrip.test.ts @@ -50,6 +50,7 @@ const { mockAuditRecord, mockWithAuditContext, mockAssertSuperAdmin, + mockCreateFromOAuthCallback, } = vi.hoisted(() => ({ mockFindMessengerIntegration: vi.fn(), mockFindInstagramIntegration: vi.fn(), @@ -83,6 +84,7 @@ const { async (_ctx: unknown, fn: () => Promise) => await fn(), ), mockAssertSuperAdmin: vi.fn(async () => undefined), + mockCreateFromOAuthCallback: vi.fn(async () => ({ integrationId: "int-1" })), })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -113,6 +115,9 @@ vi.mock("@chatbotx.io/business", () => ({ createGoogleFromOAuthCallback: mockCreateGoogleFromOAuthCallback, }, integrationFacebookAdsService: { upsert: vi.fn() }, + integrationService: { + createFromOAuthCallback: mockCreateFromOAuthCallback, + }, // Real `@/lib/oauth-referer` calls this to decide whether a foreign // `referer` host is nonetheless one we control (a white-label custom // domain) — defaults to "no active domain" so a foreign origin is never @@ -126,16 +131,6 @@ vi.mock("@chatbotx.io/business", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: vi.fn() }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationGoogleSheetsModel: {}, - integrationModel: {}, - ROOT_TENANT_ID: "1", -})) - vi.mock("@chatbotx.io/integration-facebook-ads", () => ({ exchangeCodeForToken: mockExchangeFacebookAdsCode, exchangeLongLivedToken: mockExchangeFacebookAdsLongLivedToken, diff --git a/apps/builder/__tests__/messenger-clone-template-components.test.ts b/apps/builder/__tests__/messenger-clone-template-components.test.ts index a0d04e41de..693f203df1 100644 --- a/apps/builder/__tests__/messenger-clone-template-components.test.ts +++ b/apps/builder/__tests__/messenger-clone-template-components.test.ts @@ -7,14 +7,12 @@ vi.mock("@chatbotx.io/integration-messenger/apis/upload", () => ({ resumableUploadImage, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: {}, - inArray: vi.fn(), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationMessengerModel: {}, - messengerMessageTemplateModel: {}, +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationMessengerRepository: { + findMessageTemplateForClone: vi.fn(), + findPageIdById: vi.fn(), + listByIds: vi.fn(), + }, })) vi.mock("@chatbotx.io/integration-messenger/apis/message-templates", () => ({ diff --git a/apps/builder/__tests__/oauth-reconnect-callback.test.ts b/apps/builder/__tests__/oauth-reconnect-callback.test.ts index 8ddaeae10e..73cf9f5c68 100644 --- a/apps/builder/__tests__/oauth-reconnect-callback.test.ts +++ b/apps/builder/__tests__/oauth-reconnect-callback.test.ts @@ -44,6 +44,7 @@ const { mockAuditRecord, mockWithAuditContext, mockAssertSuperAdmin, + mockCreateFromOAuthCallback, } = vi.hoisted(() => ({ mockFindMessengerIntegration: vi.fn(), mockUpdateMessengerIntegrationAuth: vi.fn(), @@ -89,6 +90,7 @@ const { async (_ctx: unknown, fn: () => Promise) => await fn(), ), mockAssertSuperAdmin: vi.fn(async () => undefined), + mockCreateFromOAuthCallback: vi.fn(async () => ({ integrationId: "int-1" })), })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -119,6 +121,9 @@ vi.mock("@chatbotx.io/business", () => ({ createGoogleFromOAuthCallback: mockCreateGoogleFromOAuthCallback, }, integrationFacebookAdsService: { upsert: mockUpsertFacebookAds }, + integrationService: { + createFromOAuthCallback: mockCreateFromOAuthCallback, + }, platformCredentialService: { resolveForOwner: mockResolveForOwner }, hasWorkspaceAccess: mockHasWorkspaceAccess, workspaceService: { @@ -127,16 +132,6 @@ vi.mock("@chatbotx.io/business", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: vi.fn() }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationGoogleSheetsModel: {}, - integrationModel: {}, - ROOT_TENANT_ID: "1", -})) - vi.mock("@chatbotx.io/integration-facebook-ads", () => ({ exchangeCodeForToken: mockExchangeFacebookAdsCode, exchangeLongLivedToken: mockExchangeFacebookAdsLongLivedToken, diff --git a/apps/builder/__tests__/update-messenger-action.test.ts b/apps/builder/__tests__/update-messenger-action.test.ts index 4027a53acd..6685169230 100644 --- a/apps/builder/__tests__/update-messenger-action.test.ts +++ b/apps/builder/__tests__/update-messenger-action.test.ts @@ -2,31 +2,23 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const txWhere = vi.fn().mockResolvedValue(undefined) - const txSet = vi.fn(() => ({ where: txWhere })) - const txUpdate = vi.fn(() => ({ set: txSet })) - - return { - buildContext: vi.fn(), - dbTransaction: vi.fn( - async (callback: (tx: { update: typeof txUpdate }) => Promise) => - callback({ update: txUpdate }), - ), - encodeButtonPayload: vi.fn(() => "encoded-payload"), - ensureMessengerWhitelistedDomain: vi.fn().mockResolvedValue(undefined), - findIntegrationMessenger: vi.fn(), - moveBrandingMenuLast: vi.fn((menus: unknown[]) => menus), - runAction: vi.fn(), - runChannelHandler: vi.fn(), - txSet, - txUpdate, - txWhere, - } -}) +const mocks = vi.hoisted(() => ({ + buildContext: vi.fn(), + encodeButtonPayload: vi.fn(() => "encoded-payload"), + ensureMessengerWhitelistedDomain: vi.fn().mockResolvedValue(undefined), + findByIdForWorkspace: vi.fn(), + moveBrandingMenuLast: vi.fn((menus: unknown[]) => menus), + runAction: vi.fn(), + runChannelHandler: vi.fn(), + updateSettings: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@chatbotx.io/business", () => ({ buildContext: mocks.buildContext, + messengerIntegrationService: { + findByIdForWorkspace: mocks.findByIdForWorkspace, + updateSettings: mocks.updateSettings, + }, })) vi.mock("@chatbotx.io/business/branding", () => ({ @@ -37,15 +29,6 @@ vi.mock("@chatbotx.io/business/errors", () => ({ ChatbotXException: class ChatbotXException extends Error {}, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mocks.dbTransaction }, - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationMessengerModel: { id: "id" }, -})) - vi.mock("@chatbotx.io/flow-config", () => ({ encodeButtonPayload: mocks.encodeButtonPayload, })) @@ -78,33 +61,30 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("../src/features/integration-messenger/queries", () => ({ - findIntegrationMessenger: mocks.findIntegrationMessenger, -})) - const { updateMessenger } = await import( "../src/features/integration-messenger/actions/update-messenger-action" ) +const baseIntegration = { + id: "messenger-1", + auth: { tokens: { accessToken: "token-1" } }, + personas: [], + persistentMenus: [], + conversationStarters: [], + welcomeFlowId: null, +} + describe("updateMessenger", () => { beforeEach(() => { vi.clearAllMocks() - mocks.findIntegrationMessenger.mockResolvedValue({ - id: "messenger-1", - auth: { tokens: { accessToken: "token-1" } }, - personas: [], - persistentMenus: [], - conversationStarters: [], - welcomeFlowId: null, - }) + mocks.findByIdForWorkspace.mockResolvedValue({ ...baseIntegration }) mocks.buildContext.mockResolvedValue({ platform: { appUrl: "https://app.example.test" }, }) mocks.runAction.mockResolvedValue({ personas: [] }) mocks.runChannelHandler.mockResolvedValue(undefined) mocks.ensureMessengerWhitelistedDomain.mockResolvedValue(undefined) - mocks.txSet.mockReturnValue({ where: mocks.txWhere }) - mocks.txWhere.mockResolvedValue(undefined) + mocks.updateSettings.mockResolvedValue(undefined) }) test("keeps saved settings when post-commit profile field deletion fails", async () => { @@ -130,7 +110,7 @@ describe("updateMessenger", () => { }, ) - expect(mocks.txUpdate).toHaveBeenCalled() + expect(mocks.updateSettings).toHaveBeenCalled() expect(mocks.ensureMessengerWhitelistedDomain).toHaveBeenCalled() expect(mocks.runChannelHandler).toHaveBeenCalledWith( "bot", @@ -140,4 +120,36 @@ describe("updateMessenger", () => { }), ) }) + + test("throws before writing when the default persona failed to register with Facebook", async () => { + mocks.runAction.mockResolvedValue({ + personas: [{ id: "persona-1", facebookPersonaId: undefined }], + }) + + await expect( + updateMessenger( + { + workspace: { id: "workspace-1" } as never, + id: "messenger-1", + }, + { + welcomeFlowId: null, + persistentMenus: [], + personas: [ + { + id: "persona-1", + isDefault: true, + name: "Support", + profilePicture: { url: "https://example.test/avatar.png" }, + } as never, + ], + conversationStarters: [], + }, + ), + ).rejects.toThrow( + "Couldn't register the default persona with Facebook. Please try saving again.", + ) + + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) }) diff --git a/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts b/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts index b9ddcff0b5..aa687f0b1b 100644 --- a/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts +++ b/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts @@ -24,19 +24,17 @@ const { addSystemUserMock, auditRecordMock, buildContextMock, - connectChannelIntegrationMock, + connectPhoneNumberMock, createSignupSessionMock, consumeSignupSessionMock, findActiveSignupSessionMock, createIdMock, - dbTransactionMock, exchangeAccessTokenMock, findConnectedPhoneNumberIdsMock, findWabaMock, getCoexistEligibilityMock, getSharedWabaIdMock, invalidateCacheByTagsMock, - isUniqueViolationErrorMock, listPhoneNumbersMock, platformCredentialResolveMock, recordRegistrationOutcomeMock, @@ -44,25 +42,21 @@ const { shareCreditLineMock, subscribeWebhookMock, updateWorkspaceLogoMock, - workspaceCreateMock, - workspaceFindMock, } = vi.hoisted(() => ({ addSystemUserMock: vi.fn(), auditRecordMock: vi.fn().mockResolvedValue(undefined), buildContextMock: vi.fn(), - connectChannelIntegrationMock: vi.fn(), + connectPhoneNumberMock: vi.fn(), createSignupSessionMock: vi.fn(), consumeSignupSessionMock: vi.fn(), findActiveSignupSessionMock: vi.fn(), createIdMock: vi.fn(), - dbTransactionMock: vi.fn(), exchangeAccessTokenMock: vi.fn(), findConnectedPhoneNumberIdsMock: vi.fn(), findWabaMock: vi.fn(), getCoexistEligibilityMock: vi.fn(), getSharedWabaIdMock: vi.fn(), invalidateCacheByTagsMock: vi.fn(), - isUniqueViolationErrorMock: vi.fn(), listPhoneNumbersMock: vi.fn(), platformCredentialResolveMock: vi.fn(), recordRegistrationOutcomeMock: vi.fn(), @@ -70,8 +64,6 @@ const { shareCreditLineMock: vi.fn(), subscribeWebhookMock: vi.fn(), updateWorkspaceLogoMock: vi.fn(), - workspaceCreateMock: vi.fn(), - workspaceFindMock: vi.fn(), })) vi.mock("@/lib/safe-action", () => { @@ -95,7 +87,15 @@ vi.mock("@/lib/log", () => ({ })) vi.mock("@chatbotx.io/business/errors", () => ({ - ChatbotXException: class ChatbotXException extends Error {}, + ChatbotXException: class ChatbotXException extends Error { + code: string + httpStatusCode: number + constructor(message: string, code = "systemError", httpStatusCode = 400) { + super(message) + this.code = code + this.httpStatusCode = httpStatusCode + } + }, })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -104,13 +104,14 @@ vi.mock("@chatbotx.io/business/audit", () => ({ vi.mock("@chatbotx.io/business", () => ({ buildContext: buildContextMock, - connectChannelIntegration: connectChannelIntegrationMock, integrationWhatsappService: { + connectPhoneNumber: connectPhoneNumberMock, createSignupSession: createSignupSessionMock, consumeSignupSession: consumeSignupSessionMock, findActiveSignupSession: findActiveSignupSessionMock, findConnectedPhoneNumberIds: findConnectedPhoneNumberIdsMock, recordRegistrationOutcome: recordRegistrationOutcomeMock, + markWebhookOverrideOk: vi.fn().mockResolvedValue(undefined), // Post-connect CAPI scope cache refresh (Phase 2 CTWA); connect flow // treats its result as best-effort, so a resolved null is sufficient. refreshCapiScopeCache: vi.fn().mockResolvedValue(null), @@ -119,27 +120,6 @@ vi.mock("@chatbotx.io/business", () => ({ platformCredentialService: { resolveForOwner: platformCredentialResolveMock, }, - workspaceService: { - create: workspaceCreateMock, - find: workspaceFindMock, - }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - transaction: dbTransactionMock, - }, - eq: (left: unknown, right: unknown) => ({ left, right }), - isUniqueViolationError: isUniqueViolationErrorMock, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationWhatsappModel: { - inboxId: "inboxId", - id: "id", - }, - WHATSAPP_PHONE_NUMBER_UNIQUE_CONSTRAINT: - "IntegrationWhatsapp_phoneNumberId_key", })) vi.mock("@chatbotx.io/integration-whatsapp", () => ({ @@ -257,16 +237,12 @@ const integrationRow = { describe("connectWhatsappAction registration", () => { beforeEach(() => { vi.clearAllMocks() - - // The happy paths below never collide on the phone number; the tests that - // exercise the constraint opt in explicitly. - isUniqueViolationErrorMock.mockReturnValue(false) + createIdMock.mockReset() createIdMock .mockReturnValueOnce("integration-1") .mockReturnValueOnce("inbox-source-id") - workspaceFindMock.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" }) platformCredentialResolveMock.mockResolvedValue({ config: { clientId: "client-1", @@ -280,8 +256,8 @@ describe("connectWhatsappAction registration", () => { businessId: "", }, }) - // The session is read up front and only spent inside the write transaction, - // so the two halves return different shapes: the payload, then whether the + // The session is read up front and only spent inside the write, so the + // two halves return different shapes: the payload, then whether the // claim was still unspent. findActiveSignupSessionMock.mockResolvedValue({ accessToken: "access-token-1", @@ -314,34 +290,15 @@ describe("connectWhatsappAction registration", () => { shareCreditLineMock.mockResolvedValue(undefined) buildContextMock.mockResolvedValue({}) updateWorkspaceLogoMock.mockResolvedValue(undefined) - workspaceCreateMock.mockResolvedValue({ - id: "ws-new", - name: selectedPhoneNumber.verified_name, - }) subscribeWebhookMock.mockResolvedValue(undefined) invalidateCacheByTagsMock.mockResolvedValue(undefined) - connectChannelIntegrationMock.mockImplementation( - async (props: { - insertIntegration: (inboxId: string) => Promise - }) => { - await props.insertIntegration("inbox-1") - return { wasCreated: true } - }, - ) - - const insertBuilder = { - values: vi.fn(), - onConflictDoUpdate: vi.fn(), - returning: vi.fn().mockResolvedValue([integrationRow]), - } - insertBuilder.values.mockReturnValue(insertBuilder) - insertBuilder.onConflictDoUpdate.mockReturnValue(insertBuilder) - dbTransactionMock.mockImplementation( - async ( - callback: (tx: { insert: () => typeof insertBuilder }) => unknown, - ) => await callback({ insert: () => insertBuilder }), - ) + connectPhoneNumberMock.mockResolvedValue({ + workspaceId: "ws-1", + createdWorkspace: false, + integrationRow, + wasCreated: true, + }) }) test("does not register the selected phone number when the selected phone is eligible for coexist", async () => { @@ -372,6 +329,13 @@ describe("connectWhatsappAction registration", () => { }) test("audits workspace creation before channel connect when connecting the first WhatsApp workspace", async () => { + connectPhoneNumberMock.mockResolvedValueOnce({ + workspaceId: "ws-new", + createdWorkspace: true, + integrationRow: { ...integrationRow, workspaceId: "ws-new" }, + wasCreated: true, + }) + await callConnectWhatsappAction({ ctx: { user: { id: "user-1" } }, parsedInput: { @@ -389,13 +353,11 @@ describe("connectWhatsappAction registration", () => { }, }) - expect(workspaceCreateMock).toHaveBeenCalledWith( + expect(connectPhoneNumberMock).toHaveBeenCalledWith( expect.objectContaining({ - createdBy: "user-1", - data: expect.objectContaining({ - name: selectedPhoneNumber.verified_name, - ownerId: "user-1", - }), + ownerId: expect.any(String), + userId: "user-1", + workspaceId: null, }), ) expect(auditRecordMock).toHaveBeenNthCalledWith(1, { @@ -574,12 +536,17 @@ describe("connectWhatsappAction registration", () => { ).rejects.toThrow( "Your WhatsApp signup session has expired. Please start the connection again.", ) - expect(consumeSignupSessionMock).not.toHaveBeenCalled() - expect(dbTransactionMock).not.toHaveBeenCalled() + expect(connectPhoneNumberMock).not.toHaveBeenCalled() }) test("aborts the connect when the session was already spent by a concurrent request", async () => { - consumeSignupSessionMock.mockResolvedValueOnce(false) + connectPhoneNumberMock.mockRejectedValueOnce( + new ChatbotXException( + "Signup session expired", + "whatsappSignupSessionExpired", + 409, + ), + ) await expect( callConnectWhatsappAction({ @@ -607,9 +574,12 @@ describe("connectWhatsappAction registration", () => { // The pre-flight check can pass and still lose the race, so the constraint // is the real gate; its violation has to read like the pre-flight rejection // rather than a raw Postgres error. - isUniqueViolationErrorMock.mockReturnValue(true) - dbTransactionMock.mockRejectedValueOnce( - new Error("duplicate key value violates unique constraint"), + connectPhoneNumberMock.mockRejectedValueOnce( + new ChatbotXException( + "Phone number already connected", + "whatsappPhoneNumberAlreadyConnected", + 409, + ), ) await expect( @@ -659,20 +629,20 @@ describe("connectWhatsappAction registration", () => { ).rejects.toThrow( "This WhatsApp number is already connected to another workspace.", ) - expect(dbTransactionMock).not.toHaveBeenCalled() + expect(connectPhoneNumberMock).not.toHaveBeenCalled() }) test("surfaces the real quota error instead of the generic token-verification message", async () => { // Regression guard: a typed ChatbotXException raised deep inside - // connectChannelIntegration (e.g. InboxService.create hitting the - // owner's channel quota) must reach the caller verbatim rather than - // being swallowed by the outer catch-all's "unable to verify token" - // fallback. + // integrationWhatsappService.connectPhoneNumber (e.g. InboxService.create + // hitting the owner's channel quota) must reach the caller verbatim + // rather than being swallowed by the outer catch-all's "unable to verify + // token" fallback. const quotaError = new ChatbotXException( "Channel limit reached for this plan", + "channelLimitReached", ) - Object.assign(quotaError, { code: "channelLimitReached" }) - connectChannelIntegrationMock.mockRejectedValueOnce(quotaError) + connectPhoneNumberMock.mockRejectedValueOnce(quotaError) await expect( callConnectWhatsappAction({ diff --git a/apps/builder/src/app/integrations/[...integration]/callback.ts b/apps/builder/src/app/integrations/[...integration]/callback.ts index 1270abf4fb..0bdcef264c 100644 --- a/apps/builder/src/app/integrations/[...integration]/callback.ts +++ b/apps/builder/src/app/integrations/[...integration]/callback.ts @@ -4,6 +4,7 @@ import { instagramIntegrationService, integrationFacebookAdsService, integrationMetaCatalogService, + integrationService, integrationWhatsappService, messagingAdsConnectionService, messengerIntegrationService, @@ -11,16 +12,11 @@ import { workspaceService, } from "@chatbotx.io/business" import { auditService, withAuditContext } from "@chatbotx.io/business/audit" -import { db } from "@chatbotx.io/database/client" import { type IntegrationType, type MessagingAdChannel, messagingAdChannelTypes, } from "@chatbotx.io/database/partials" -import { - integrationGoogleSheetsModel, - integrationModel, -} from "@chatbotx.io/database/schema" import { exchangeCodeForToken as exchangeFacebookAdsCode, exchangeLongLivedToken as exchangeFacebookAdsLongLivedToken, @@ -43,11 +39,7 @@ import { type AuthValue, type Oauth2AuthValue, } from "@chatbotx.io/sdk" -import { - createId, - getPublicUrlFromRequest, - zodBigintAsString, -} from "@chatbotx.io/utils" +import { getPublicUrlFromRequest, zodBigintAsString } from "@chatbotx.io/utils" import { cookies } from "next/headers" import { notFound, redirect } from "next/navigation" import type { NextRequest } from "next/server" @@ -877,22 +869,10 @@ export const handleCallback = async ( return notFound() } - await db.transaction(async (tx) => { - const integrationId = createId() - - await tx.insert(integrationModel).values({ - id: integrationId, - workspaceId: workspace.id, - integrationType, - }) - - if (integrationType === "googleSheets" && googleSheetsAuth) { - await tx.insert(integrationGoogleSheetsModel).values({ - workspaceId: workspace.id, - integrationId, - auth: googleSheetsAuth, - }) - } + await integrationService.createFromOAuthCallback({ + workspaceId: workspace.id, + integrationType, + googleSheetsAuth, }) if (integrationType === "googleSheets") { diff --git a/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts b/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts index a25066e55f..1d58cf3128 100644 --- a/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts +++ b/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts @@ -1,13 +1,10 @@ import { - coexistService, - inboxService, + deleteInstagramIntegrationWithCleanup, + instagramIntegrationService, messengerIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" -import { integrationInstagramModel } from "@chatbotx.io/database/schema" import { type InstagramAuthValue, isRevokedTokenError, @@ -21,17 +18,17 @@ export const disconnectInstagram = async (ctx: { integrationInstagramId: string }) => { const [integrationInstagram, workspace] = await Promise.all([ - findOrFail({ - table: integrationInstagramModel, - where: { - id: ctx.integrationInstagramId, - workspaceId: ctx.workspaceId, - }, - message: "Integration Instagram not found", + instagramIntegrationService.findByIdForWorkspace({ + id: ctx.integrationInstagramId, + workspaceId: ctx.workspaceId, }), workspaceService.findById({ id: ctx.workspaceId }), ]) + if (!integrationInstagram) { + throw new Error("Integration Instagram not found") + } + const authValue = integrationInstagram.auth as InstagramAuthValue const isFacebook = integrationInstagram.type === "facebook" @@ -66,41 +63,12 @@ export const disconnectInstagram = async (ctx: { } } - await db.transaction(async (tx) => { - // Coexist only exists for the native Instagram integration; the Facebook- - // mediated variant (type "facebook") never has coexist runs. Gate explicitly - // so the intent is clear at the call site (mirrors workspace-lifecycle). - if (!isFacebook) { - await coexistService.tearDownForIntegration({ - workspaceId: ctx.workspaceId, - integrationId: integrationInstagram.id, - channel: "instagram", - currentError: "Integration disconnected", - tx, - }) - } - - // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep occupying - // the (workspaceId, channel, sourceKey) dedup slot after a reconnect. - await metaCapiEventRepository.deleteByIntegration( - { - workspaceId: ctx.workspaceId, - channel: "instagram", - integrationId: integrationInstagram.id, - }, - tx, - ) - - await tx - .delete(integrationInstagramModel) - .where(eq(integrationInstagramModel.id, integrationInstagram.id)) - - await inboxService.disconnect({ - inboxId: integrationInstagram.inboxId, - ownerId: workspace.ownerId, - workspaceId: ctx.workspaceId, - tx, - }) + await deleteInstagramIntegrationWithCleanup({ + workspaceId: ctx.workspaceId, + id: integrationInstagram.id, + inboxId: integrationInstagram.inboxId, + ownerId: workspace.ownerId, + isFacebook, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-instagram/actions/select-account-facebook.action.ts b/apps/builder/src/features/integration-instagram/actions/select-account-facebook.action.ts index d431dcff25..dd30712070 100644 --- a/apps/builder/src/features/integration-instagram/actions/select-account-facebook.action.ts +++ b/apps/builder/src/features/integration-instagram/actions/select-account-facebook.action.ts @@ -2,16 +2,13 @@ import { buildContext, - connectChannelIntegration, + connectInstagramAccount, instagramIntegrationService, platformCredentialService, - resolveTenantSettings, - workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, isDatabaseError } from "@chatbotx.io/database/client" -import { integrationInstagramModel } from "@chatbotx.io/database/schema" +import { isDatabaseError } from "@chatbotx.io/database/client" import type { UserModel } from "@chatbotx.io/database/types" import type { InstagramAuthValue } from "@chatbotx.io/integration-instagram-facebook" import { @@ -19,7 +16,6 @@ import { subscribePageToInstagramWebhook, } from "@chatbotx.io/integration-instagram-facebook" import { AuthType, SdkException } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils/id" import { redirect } from "next/navigation" import { BRANDING_TITLE, @@ -79,91 +75,50 @@ export const selectFacebookAccountAction = authActionClient ) } + const auth: InstagramAuthValue = { + authType: AuthType.oauth2, + clientId: instagramSettings.clientId, + clientSecret: instagramSettings.clientSecret, + redirectUrl: "", + tokens: { + accessToken: parsedInput.pageAccessToken, + }, + metadata: { + igId: parsedInput.igId, + igName: parsedInput.igName, + pageId: parsedInput.pageId, + version: parsedInput.version ?? instagramSettings.version, + }, + } + // DB work only — no external API calls inside the transaction so a // rolled-back commit doesn't leave orphaned Facebook webhook subscriptions. - const { appUrl, createdWorkspace, integrationRow, wasCreated } = - await db.transaction(async (tx) => { - let createdWorkspace = false - - if (!workspaceId) { - const workspace = await workspaceService.create({ - tx, - createdBy: ctx.user.id, - data: { - name: parsedInput.igName, - timezone: "UTC", - ownerId: ctx.user.id, - }, - }) - workspaceId = workspace.id - createdWorkspace = true - } - - const { appUrl } = await resolveTenantSettings({ - workspaceId, - tx, - }) - - const auth: InstagramAuthValue = { - authType: AuthType.oauth2, - clientId: instagramSettings.clientId, - clientSecret: instagramSettings.clientSecret, - redirectUrl: "", - tokens: { - accessToken: parsedInput.pageAccessToken, - }, - metadata: { - igId: parsedInput.igId, - igName: parsedInput.igName, - pageId: parsedInput.pageId, - version: parsedInput.version ?? instagramSettings.version, - }, - } - - const { integration: integrationRow, wasCreated } = - await connectChannelIntegration({ - tx, - ownerId, - inboxData: { - id: createId(), - workspaceId: workspaceId as string, - name: parsedInput.igName, - channel: "instagram", - sourceId: parsedInput.igId, - }, - insertIntegration: async (inboxId) => - tx - .insert(integrationInstagramModel) - .values({ - id: createId(), - workspaceId: workspaceId as string, - inboxId, - igId: parsedInput.igId, - pageId: parsedInput.pageId, - auth, - name: parsedInput.igName, - username: parsedInput.igUsername, - type: "facebook", - persistentMenus: [ - { - label: BRANDING_TITLE, - type: "url" as const, - url: getBrandingUrl("instagram", appUrl), - }, - ], - conversationStarters: [], - }) - .returning() - .then((result) => result[0]), - }) + const { + workspaceId: connectedWorkspaceId, + appUrl, + createdWorkspace, + integrationRow, + wasCreated, + } = await connectInstagramAccount({ + ownerId, + userId: ctx.user.id, + workspaceId, + igId: parsedInput.igId, + igName: parsedInput.igName, + igUsername: parsedInput.igUsername, + pageId: parsedInput.pageId, + auth, + type: "facebook", + buildPersistentMenus: (resolvedAppUrl) => [ + { + label: BRANDING_TITLE, + type: "url" as const, + url: getBrandingUrl("instagram", resolvedAppUrl), + }, + ], + }) - return { - integrationRow, - appUrl, - createdWorkspace, - wasCreated, - } - }) + workspaceId = connectedWorkspaceId if (createdWorkspace) { await auditService.record({ diff --git a/apps/builder/src/features/integration-instagram/actions/select-account.action.ts b/apps/builder/src/features/integration-instagram/actions/select-account.action.ts index 2d3fa166ec..0f15904336 100644 --- a/apps/builder/src/features/integration-instagram/actions/select-account.action.ts +++ b/apps/builder/src/features/integration-instagram/actions/select-account.action.ts @@ -2,16 +2,13 @@ import { buildContext, - connectChannelIntegration, + connectInstagramAccount, instagramIntegrationService, platformCredentialService, - resolveTenantSettings, - workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, isDatabaseError } from "@chatbotx.io/database/client" -import { integrationInstagramModel } from "@chatbotx.io/database/schema" +import { isDatabaseError } from "@chatbotx.io/database/client" import type { UserModel } from "@chatbotx.io/database/types" import type { InstagramAuthValue } from "@chatbotx.io/integration-instagram" import { @@ -19,7 +16,6 @@ import { subscribePageToInstagramWebhook, } from "@chatbotx.io/integration-instagram" import { AuthType } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils/id" import { redirect } from "next/navigation" import { BRANDING_TITLE, @@ -62,123 +58,88 @@ export const selectAccountAction = authActionClient } const instagramSettings = instagramCredential.config - const { brandingCtx, createdWorkspace, integrationId, wasCreated } = - await db.transaction(async (tx) => { - let createdWorkspace = false - - if (!workspaceId) { - const workspace = await workspaceService.create({ - tx, - createdBy: ctx.user.id, - data: { - name: parsedInput.igName, - timezone: "UTC", - ownerId: ctx.user.id, - }, - }) - workspaceId = workspace.id - createdWorkspace = true - } - - const { appUrl } = await resolveTenantSettings({ - workspaceId, - tx, - }) - - await subscribePageToInstagramWebhook({ - igId: parsedInput.pageId, - accessToken: parsedInput.accessToken, - version: instagramSettings.version, - }) + const auth: InstagramAuthValue = { + authType: AuthType.oauth2, + clientId: instagramSettings.clientId, + clientSecret: instagramSettings.clientSecret, + redirectUrl: "", + tokens: { + accessToken: parsedInput.accessToken, + }, + metadata: { + igId: parsedInput.igId, + igName: parsedInput.igName, + pageId: parsedInput.pageId, + version: instagramSettings.version, + }, + } - const auth: InstagramAuthValue = { - authType: AuthType.oauth2, - clientId: instagramSettings.clientId, - clientSecret: instagramSettings.clientSecret, - redirectUrl: "", - tokens: { - accessToken: parsedInput.accessToken, - }, - metadata: { - igId: parsedInput.igId, - igName: parsedInput.igName, - pageId: parsedInput.pageId, - version: instagramSettings.version, - }, - } + // NOTE (behavior change / PR-recorded deviation): the webhook + // subscribe and branding call used to run INSIDE the same DB + // transaction as the insert. Moving the connect's DB body into + // `connectInstagramAccount` (packages/business has no dependency on + // `@chatbotx.io/integration-instagram`) forces both out of the + // transaction. The webhook subscribe now runs BEFORE the write so a + // failed subscribe still prevents the connect (nothing to roll back, + // nothing orphaned) — same shape as the Messenger select-page action. + // Branding stays best-effort after the write. See + // `apps/builder/__tests__/instagram-select-account-ordering.test.ts`. + await subscribePageToInstagramWebhook({ + igId: parsedInput.pageId, + accessToken: parsedInput.accessToken, + version: instagramSettings.version, + }) - const { integration: integrationRow, wasCreated } = - await connectChannelIntegration({ - tx, - ownerId, - inboxData: { - id: createId(), - workspaceId: workspaceId as string, - name: parsedInput.igName, - channel: "instagram", - sourceId: parsedInput.igId, - }, - insertIntegration: async (inboxId) => - tx - .insert(integrationInstagramModel) - .values({ - id: createId(), - workspaceId: workspaceId as string, - inboxId, - igId: parsedInput.igId, - pageId: parsedInput.pageId, - auth, - name: parsedInput.igName, - username: parsedInput.igUsername, - persistentMenus: [ - { - label: BRANDING_TITLE, - type: "url" as const, - url: getBrandingUrl("instagram", appUrl), - }, - ], - conversationStarters: [], - }) - .returning() - .then((result) => result[0]), - }) + const { + workspaceId: connectedWorkspaceId, + appUrl, + createdWorkspace, + integrationRow, + wasCreated, + } = await connectInstagramAccount({ + ownerId, + userId: ctx.user.id, + workspaceId, + igId: parsedInput.igId, + igName: parsedInput.igName, + igUsername: parsedInput.igUsername, + pageId: parsedInput.pageId, + auth, + buildPersistentMenus: (resolvedAppUrl) => [ + { + label: BRANDING_TITLE, + type: "url" as const, + url: getBrandingUrl("instagram", resolvedAppUrl), + }, + ], + }) - const brandingCtx = await buildContext({ - workspaceId, - integrationType: "instagram", - integration: { - ...integrationRow, - auth: integrationRow.auth as InstagramAuthValue, - }, - }) + workspaceId = connectedWorkspaceId + const integrationId = integrationRow.id - // Best-effort: the connection is already live, so a failed - // branding write must never roll back the transaction or fail - // the action. - try { - await integrationInstagram.runChannelHandler( - "bot", - "addBranding", - { - ctx: brandingCtx, - title: BRANDING_TITLE, - url: getBrandingUrl("instagram", appUrl), - }, - ) - } catch (error) { - logger.warn( - { err: error }, - "Failed to add branding to Instagram persistent menu", - ) - } + const brandingCtx = await buildContext({ + workspaceId, + integrationType: "instagram", + integration: { + ...integrationRow, + auth: integrationRow.auth as InstagramAuthValue, + }, + }) - return { - brandingCtx, - createdWorkspace, - integrationId: integrationRow.id, - wasCreated, - } + // Best-effort: the connection is already live, so a failed branding + // write must never fail the action. + try { + await integrationInstagram.runChannelHandler("bot", "addBranding", { + ctx: brandingCtx, + title: BRANDING_TITLE, + url: getBrandingUrl("instagram", appUrl), }) + } catch (error) { + logger.warn( + { err: error }, + "Failed to add branding to Instagram persistent menu", + ) + } // Best-effort: the connection is already live, so a failed user-info // write must never fail the action. Direct Instagram login diff --git a/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts b/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts index eb4445ebb9..9d97c827c8 100644 --- a/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts +++ b/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts @@ -1,18 +1,18 @@ "use server" -import { buildContext } from "@chatbotx.io/business" +import { + buildContext, + instagramIntegrationService, +} from "@chatbotx.io/business" import { moveBrandingMenuLast } from "@chatbotx.io/business/branding" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { findOrFail } from "@chatbotx.io/database/client" import { type InstagramConversationStarter, type InstagramPersistentMenu, instagramPersistentMenuTypes, } from "@chatbotx.io/database/partials" -import { - flowVersionModel, - integrationInstagramModel, -} from "@chatbotx.io/database/schema" +import { flowVersionModel } from "@chatbotx.io/database/schema" import type { IntegrationInstagramModel, WorkspaceModel, @@ -33,7 +33,6 @@ import { import { getBrandingUrl } from "@/features/integration-webchat/lib" import { logger } from "@/lib/log" import { workspaceActionClient } from "@/lib/safe-action" -import { findIntegrationInstagram } from "../queries" import { type UpdateInstagramRequest, updateInstagramRequest, @@ -53,72 +52,68 @@ export const updateInstagramAction = workspaceActionClient bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { try { - await db.transaction(async (tx) => { - const integrationInstagramData = await findIntegrationInstagram({ + const integrationInstagramData = + await instagramIntegrationService.findByIdForWorkspace({ workspaceId: ctx.workspace.id, id, }) - await tx - .update(integrationInstagramModel) - .set({ - welcomeFlowId: parsedInput.welcomeFlowId, - conversationStarters: parsedInput.conversationStarters, - persistentMenus: parsedInput.persistentMenus, - }) - .where(eq(integrationInstagramModel.id, id)) + await instagramIntegrationService.updateSettings({ + workspaceId: ctx.workspace.id, + id, + values: { + welcomeFlowId: parsedInput.welcomeFlowId, + conversationStarters: parsedInput.conversationStarters, + persistentMenus: parsedInput.persistentMenus, + }, + }) - if (integrationInstagramData) { - const auth = integrationInstagramData.auth as InstagramAuthValue - const isFacebook = integrationInstagramData.type === "facebook" - const channelIntegration = isFacebook - ? integrationInstagramFacebook - : integrationInstagram - const botContext = await buildContext({ - workspaceId: ctx.workspace.id, - integrationType: isFacebook ? "instagramFacebook" : "instagram", - integration: { ...integrationInstagramData, auth }, - }) + if (integrationInstagramData) { + const auth = integrationInstagramData.auth as InstagramAuthValue + const isFacebook = integrationInstagramData.type === "facebook" + const channelIntegration = isFacebook + ? integrationInstagramFacebook + : integrationInstagram + const botContext = await buildContext({ + workspaceId: ctx.workspace.id, + integrationType: isFacebook ? "instagramFacebook" : "instagram", + integration: { ...integrationInstagramData, auth }, + }) - const fieldsToDelete = getInstagramFieldsToDelete(parsedInput) - if (fieldsToDelete.length > 0) { - await channelIntegration.runChannelHandler( - "bot", - "deleteProfileFields", - { - ctx: botContext, - fields: fieldsToDelete, - }, - ) - } + const fieldsToDelete = getInstagramFieldsToDelete(parsedInput) + if (fieldsToDelete.length > 0) { + await channelIntegration.runChannelHandler( + "bot", + "deleteProfileFields", + { + ctx: botContext, + fields: fieldsToDelete, + }, + ) + } - const profileData: Partial = {} + const profileData: Partial = {} - if (parsedInput.conversationStarters.length) { - profileData.ice_breakers = await buildIceBreakersParams( - parsedInput.conversationStarters, - ) - } + if (parsedInput.conversationStarters.length) { + profileData.ice_breakers = await buildIceBreakersParams( + parsedInput.conversationStarters, + ) + } - if (parsedInput.persistentMenus.length) { - profileData.persistent_menu = await buildPersistentMenuParams( - parsedInput.persistentMenus, - botContext.platform.appUrl, - ) - } + if (parsedInput.persistentMenus.length) { + profileData.persistent_menu = await buildPersistentMenuParams( + parsedInput.persistentMenus, + botContext.platform.appUrl, + ) + } - if (Object.keys(profileData).length > 0) { - await channelIntegration.runChannelHandler( - "bot", - "updateProfile", - { - ctx: botContext, - data: profileData as InstagramProfileRequest, - }, - ) - } + if (Object.keys(profileData).length > 0) { + await channelIntegration.runChannelHandler("bot", "updateProfile", { + ctx: botContext, + data: profileData as InstagramProfileRequest, + }) } - }) + } } catch (error) { logger.error({ err: error }, "Failed to update Instagram integration") throw new ChatbotXException("Failed to update Instagram integration") diff --git a/apps/builder/src/features/integration-instagram/queries/index.ts b/apps/builder/src/features/integration-instagram/queries/index.ts index ea34de5f19..d1df02b161 100644 --- a/apps/builder/src/features/integration-instagram/queries/index.ts +++ b/apps/builder/src/features/integration-instagram/queries/index.ts @@ -1,28 +1,27 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" -import { integrationInstagramModel } from "@chatbotx.io/database/schema" +import { instagramIntegrationService } from "@chatbotx.io/business" import type { IntegrationInstagramModel } from "@chatbotx.io/database/types" export const findIntegrationInstagram = async (where: { workspaceId: string id: string -}): Promise => - findOrFail({ - table: integrationInstagramModel, - where, - }) +}): Promise => { + const integration = + await instagramIntegrationService.findByIdForWorkspace(where) + + if (!integration) { + throw new Error("Integration Instagram not found") + } + + return integration +} export const listIntegrationInstagrams = async ({ workspaceId, }: { workspaceId: string }): Promise<{ data: IntegrationInstagramModel[] }> => { - const data = await db.query.integrationInstagramModel.findMany({ - where: { - workspaceId, - }, - orderBy: { - createdAt: "asc", - }, + const data = await instagramIntegrationService.listForWorkspace({ + workspaceId, }) return { data } diff --git a/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts b/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts index 3a51e9e1ff..d47c09e7dd 100644 --- a/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts +++ b/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts @@ -1,17 +1,10 @@ import { - coexistService, - inboxService, + deleteMessengerIntegrationWithCleanup, instagramIntegrationService, + messengerIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, findOrFail } from "@chatbotx.io/database/client" -import { channelTypes } from "@chatbotx.io/database/partials" -import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" -import { - integrationMessengerModel, - tagChannelModel, -} from "@chatbotx.io/database/schema" import { isRevokedTokenError, type MessengerAuthValue, @@ -25,17 +18,17 @@ export const disconnectMessenger = async (ctx: { id: string }) => { const [integrationMessenger, workspace] = await Promise.all([ - findOrFail({ - table: integrationMessengerModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Integration Messenger not found", + messengerIntegrationService.findByIdForWorkspace({ + id: ctx.id, + workspaceId: ctx.workspaceId, }), workspaceService.findById({ id: ctx.workspaceId }), ]) + if (!integrationMessenger) { + throw new Error("Integration Messenger not found") + } + const authValue = integrationMessenger.auth as MessengerAuthValue const hasSharedInstagramIntegration = @@ -71,46 +64,11 @@ export const disconnectMessenger = async (ctx: { } } - await db.transaction(async (tx) => { - await coexistService.tearDownForIntegration({ - workspaceId: ctx.workspaceId, - integrationId: integrationMessenger.id, - channel: "messenger", - currentError: "Integration disconnected", - tx, - }) - - // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId - await tx - .delete(tagChannelModel) - .where( - and( - eq(tagChannelModel.channelType, channelTypes.enum.messenger), - eq(tagChannelModel.integrationId, integrationMessenger.id), - ), - ) - - // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep occupying - // the (workspaceId, channel, sourceKey) dedup slot after a reconnect. - await metaCapiEventRepository.deleteByIntegration( - { - workspaceId: ctx.workspaceId, - channel: "messenger", - integrationId: integrationMessenger.id, - }, - tx, - ) - - await tx - .delete(integrationMessengerModel) - .where(eq(integrationMessengerModel.id, integrationMessenger.id)) - - await inboxService.disconnect({ - inboxId: integrationMessenger.inboxId, - ownerId: workspace.ownerId, - workspaceId: ctx.workspaceId, - tx, - }) + await deleteMessengerIntegrationWithCleanup({ + workspaceId: ctx.workspaceId, + id: integrationMessenger.id, + inboxId: integrationMessenger.inboxId, + ownerId: workspace.ownerId, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-messenger/actions/select-page.action.ts b/apps/builder/src/features/integration-messenger/actions/select-page.action.ts index 117b5499d4..9f237b70d9 100644 --- a/apps/builder/src/features/integration-messenger/actions/select-page.action.ts +++ b/apps/builder/src/features/integration-messenger/actions/select-page.action.ts @@ -2,19 +2,16 @@ import { buildContext, - connectChannelIntegration, + connectMessengerPage, messengerIntegrationService, platformCredentialService, - resolveTenantSettings, tagSyncService, userQuotaService, - workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, isDatabaseError } from "@chatbotx.io/database/client" +import { isDatabaseError } from "@chatbotx.io/database/client" import { channelTypes } from "@chatbotx.io/database/partials" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { UserModel } from "@chatbotx.io/database/types" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger" import { integration as integrationMessenger } from "@chatbotx.io/integration-messenger" @@ -23,7 +20,6 @@ import { subscribePageToAppWebhook, } from "@chatbotx.io/integration-messenger/apis/page" import { AuthType, SdkException } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" import { redirect } from "next/navigation" import { isCloud } from "@/env" import { @@ -100,121 +96,85 @@ export const selectPageAction = authActionClient ) } - const { brandingCtx, createdWorkspace, wasCreated } = - await db.transaction(async (tx) => { - const longLivedToken = await exchangeLongLivedToken( - messengerSettings, - parsedInput.accessToken, - ) - let createdWorkspace = false - - if (!workspaceId) { - const workspace = await workspaceService.create({ - tx, - createdBy: ctx.user.id, - data: { - name: parsedInput.pageName, - timezone: "UTC", - ownerId: ctx.user.id, - }, - }) - workspaceId = workspace.id - createdWorkspace = true - } - - const { appUrl } = await resolveTenantSettings({ - workspaceId, - tx, - }) - - await subscribePageToAppWebhook({ - pageId: parsedInput.pageId, - accessToken: longLivedToken, - version: messengerSettings.version, - }) + const longLivedToken = await exchangeLongLivedToken( + messengerSettings, + parsedInput.accessToken, + ) - const auth: MessengerAuthValue = { - authType: AuthType.oauth2, - clientId: messengerSettings.clientId, - clientSecret: messengerSettings.clientSecret, - redirectUrl: "", - version: messengerSettings.version, - tokens: { - accessToken: longLivedToken, - }, - metadata: { - pageId: parsedInput.pageId, - pageName: parsedInput.pageName, - version: messengerSettings.version, - }, - } + // Best-effort preserve-branch: a failed webhook subscribe must not + // roll back the connect. This intentionally now runs before the DB + // write (previously inside the same transaction) — see PR notes: + // `packages/business` has no dependency on + // `@chatbotx.io/integration-messenger`, so the subscribe call can no + // longer live inside `connectMessengerPage`'s transaction. + await subscribePageToAppWebhook({ + pageId: parsedInput.pageId, + accessToken: longLivedToken, + version: messengerSettings.version, + }) - const { integration: integrationRow, wasCreated } = - await connectChannelIntegration({ - tx, - ownerId: platformOwnerId, - inboxData: { - id: createId(), - workspaceId: workspaceId as string, - name: parsedInput.pageName, - channel: "messenger", - sourceId: parsedInput.pageId, - }, - insertIntegration: async (inboxId) => - tx - .insert(integrationMessengerModel) - .values({ - id: createId(), - workspaceId: workspaceId as string, - inboxId, - pageId: parsedInput.pageId, - auth, - name: parsedInput.pageName, - persistentMenus: [ - { - label: BRANDING_TITLE, - type: "url" as const, - url: getBrandingUrl("messenger", appUrl), - }, - ], - conversationStarters: [], - personas: [], - }) - .returning() - .then((result) => result[0]), - }) + const auth: MessengerAuthValue = { + authType: AuthType.oauth2, + clientId: messengerSettings.clientId, + clientSecret: messengerSettings.clientSecret, + redirectUrl: "", + version: messengerSettings.version, + tokens: { + accessToken: longLivedToken, + }, + metadata: { + pageId: parsedInput.pageId, + pageName: parsedInput.pageName, + version: messengerSettings.version, + }, + } - integrationId = integrationRow.id - connectedIntegrationId = integrationRow?.id + const { + workspaceId: connectedWorkspaceId, + createdWorkspace, + integrationRow, + wasCreated, + appUrl, + } = await connectMessengerPage({ + ownerId: platformOwnerId, + userId: ctx.user.id, + workspaceId, + pageName: parsedInput.pageName, + pageId: parsedInput.pageId, + auth, + buildPersistentMenus: (resolvedAppUrl) => [ + { + label: BRANDING_TITLE, + type: "url" as const, + url: getBrandingUrl("messenger", resolvedAppUrl), + }, + ], + }) - const brandingCtx = await buildContext({ - workspaceId, - integrationType: "messenger", - integration: { ...integrationRow, auth }, - }) + workspaceId = connectedWorkspaceId + integrationId = integrationRow.id + connectedIntegrationId = integrationRow.id - // Best-effort: the connection is already live, so a failed - // branding write must never roll back the transaction or fail - // the action. - try { - await integrationMessenger.runChannelHandler( - "bot", - "addBranding", - { - ctx: brandingCtx, - title: BRANDING_TITLE, - url: getBrandingUrl("messenger", appUrl), - }, - ) - } catch (error) { - logger.warn( - { err: error }, - "Failed to add branding to Messenger persistent menu", - ) - } + const brandingCtx = await buildContext({ + workspaceId, + integrationType: "messenger", + integration: { ...integrationRow, auth }, + }) - return { brandingCtx, createdWorkspace, wasCreated } + // Best-effort: the connection is already live, so a failed branding + // write must never fail the action. + try { + await integrationMessenger.runChannelHandler("bot", "addBranding", { + ctx: brandingCtx, + title: BRANDING_TITLE, + url: getBrandingUrl("messenger", appUrl), }) + } catch (error) { + logger.warn( + { err: error }, + "Failed to add branding to Messenger persistent menu", + ) + } await updateWorkspaceLogo({ id: workspaceId as string, diff --git a/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts b/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts index e9c2248954..1254e75c1d 100644 --- a/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts +++ b/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts @@ -1,11 +1,12 @@ "use server" -import { buildContext, type IntegrationContext } from "@chatbotx.io/business" +import { + buildContext, + messengerIntegrationService, +} from "@chatbotx.io/business" import { moveBrandingMenuLast } from "@chatbotx.io/business/branding" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq } from "@chatbotx.io/database/client" import type { MessengerPersona } from "@chatbotx.io/database/partials" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel, WorkspaceModel, @@ -24,7 +25,6 @@ import { normalizeError } from "universal-error-normalizer" import { getBrandingUrl } from "@/features/integration-webchat/lib" import { logger } from "@/lib/log" import { workspaceActionClient } from "@/lib/safe-action" -import { findIntegrationMessenger } from "../queries" import { type UpdateMessengerRequest, updateMessengerRequest, @@ -57,63 +57,63 @@ export const updateMessenger = async ( parsedInput: UpdateMessengerRequest, ) => { try { - let botContext: IntegrationContext | undefined - let fieldsToDelete: string[] = [] - let profileParams: MessengerProfileRequest = {} - - await db.transaction(async (tx) => { - const integrationMessengerData = await findIntegrationMessenger({ + const integrationMessengerData = + await messengerIntegrationService.findByIdForWorkspace({ workspaceId: ctx.workspace.id, id: ctx.id, }) - const syncedPersonas = await syncMessengerPersonas( - ctx.workspace, - integrationMessengerData, - parsedInput.personas, - ) - const defaultPersona = syncedPersonas.find((persona) => persona.isDefault) + if (!integrationMessengerData) { + throw new Error("Messenger integration not found") + } - // A default persona that failed to register with Facebook has no - // facebookPersonaId. Persisting personaId: null here would silently drop - // the page's persona identity from every outbound message, so surface the - // failure (rolls back the tx) instead of degrading to the generic page. - if (defaultPersona && !isRegisteredPersona(defaultPersona)) { - throw new ChatbotXException( - "Couldn't register the default persona with Facebook. Please try saving again.", - ) - } + const syncedPersonas = await syncMessengerPersonas( + ctx.workspace, + integrationMessengerData, + parsedInput.personas, + ) + const defaultPersona = syncedPersonas.find((persona) => persona.isDefault) - await tx - .update(integrationMessengerModel) - .set({ - ...parsedInput, - personas: syncedPersonas, - personaId: defaultPersona?.facebookPersonaId ?? null, - }) - .where(eq(integrationMessengerModel.id, ctx.id)) + // A default persona that failed to register with Facebook has no + // facebookPersonaId. Persisting personaId: null here would silently drop + // the page's persona identity from every outbound message, so surface the + // failure — checked before the write (moved out of the tx: this guard + // previously ran before a `tx.update` in the same transaction; now the + // write is a separate business call, so the guard must run first to get + // the same "never partially persist" behavior) — instead of degrading to + // the generic page. + if (defaultPersona && !isRegisteredPersona(defaultPersona)) { + throw new ChatbotXException( + "Couldn't register the default persona with Facebook. Please try saving again.", + ) + } - botContext = await buildContext({ - workspaceId: ctx.workspace.id, - integrationType: "messenger", - integration: { - ...integrationMessengerData, - auth: integrationMessengerData.auth as MessengerAuthValue, - }, - }) + await messengerIntegrationService.updateSettings({ + workspaceId: ctx.workspace.id, + id: ctx.id, + values: { + ...parsedInput, + personas: syncedPersonas, + personaId: defaultPersona?.facebookPersonaId ?? null, + }, + }) - fieldsToDelete = getFieldsToDelete(parsedInput) - profileParams = getMessengerProfileParams( - { - ...integrationMessengerData, - ...parsedInput, - }, - botContext.platform.appUrl, - ) + const botContext = await buildContext({ + workspaceId: ctx.workspace.id, + integrationType: "messenger", + integration: { + ...integrationMessengerData, + auth: integrationMessengerData.auth as MessengerAuthValue, + }, }) - if (!botContext) { - return - } + const fieldsToDelete = getFieldsToDelete(parsedInput) + const profileParams = getMessengerProfileParams( + { + ...integrationMessengerData, + ...parsedInput, + }, + botContext.platform.appUrl, + ) if (fieldsToDelete.length > 0) { await integrationMessenger diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts index d68a9f6f7a..b238d9a27f 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts @@ -1,7 +1,6 @@ "use server" -import { db, inArray } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" import { createPageMessageTemplate } from "@chatbotx.io/integration-messenger/apis/message-templates" import { resumableUploadImage } from "@chatbotx.io/integration-messenger/apis/upload" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" @@ -140,14 +139,10 @@ export const cloneMessengerMessageTemplateAction = workspaceActionClient // Load source template, verifying it belongs to the source integration + workspace const sourceTemplate = - await db.query.messengerMessageTemplateModel.findFirst({ - where: { - id: templateId, - integrationMessengerId: sourceIntegrationMessengerId, - integrationMessenger: { - workspaceId, - }, - }, + await integrationMessengerRepository.findMessageTemplateForClone({ + workspaceId, + integrationMessengerId: sourceIntegrationMessengerId, + templateId, }) if (!sourceTemplate) { @@ -156,18 +151,15 @@ export const cloneMessengerMessageTemplateAction = workspaceActionClient // Source integration (for its pageId — never clone a template onto its own page). const sourceIntegration = - await db.query.integrationMessengerModel.findFirst({ - where: { id: sourceIntegrationMessengerId, workspaceId }, - columns: { pageId: true }, + await integrationMessengerRepository.findPageIdById({ + workspaceId, + id: sourceIntegrationMessengerId, }) // Resolve target rows by id (targets may live in OTHER workspaces). - const candidateTargets = await db - .select() - .from(integrationMessengerModel) - .where( - inArray(integrationMessengerModel.id, targetIntegrationMessengerIds), - ) + const candidateTargets = await integrationMessengerRepository.listByIds({ + ids: targetIntegrationMessengerIds, + }) // Authorize per target: the user must be an owner of the target's workspace, // and the target must not be the source's own Facebook Page. diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts index 37e5ee5383..1e2a1a7b60 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts @@ -1,6 +1,6 @@ "use server" -import { db } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" import { createPageMessageTemplate } from "@chatbotx.io/integration-messenger/apis/message-templates" import { resumableUploadImage } from "@chatbotx.io/integration-messenger/apis/upload" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" @@ -35,11 +35,9 @@ export const createMessengerMessageTemplateAction = workspaceActionClient } = props const integrationMessenger = - await db.query.integrationMessengerModel.findFirst({ - where: { - id: integrationMessengerId, - workspaceId, - }, + await messengerIntegrationService.findByIdForWorkspace({ + id: integrationMessengerId, + workspaceId, }) if (!integrationMessenger) { diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts index 3274b2e9ca..13c688ba38 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts @@ -1,7 +1,7 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" +import { messengerIntegrationService } from "@chatbotx.io/business" +import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,29 +18,19 @@ export const deleteMessengerMessageTemplateAction = workspaceActionClient } = props // Verify the integration belongs to the workspace - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { - id: integrationMessengerId, - workspaceId, - }, - columns: { id: true }, + const integration = await messengerIntegrationService.findByIdForWorkspace({ + id: integrationMessengerId, + workspaceId, }) if (!integration) { throw new Error("Messenger integration not found") } - await db - .delete(messengerMessageTemplateModel) - .where( - and( - eq(messengerMessageTemplateModel.id, templateId), - eq( - messengerMessageTemplateModel.integrationMessengerId, - integrationMessengerId, - ), - ), - ) + await integrationMessengerRepository.deleteMessageTemplate({ + integrationMessengerId, + templateId, + }) await invalidateCacheByTags([ `workspaces:${workspaceId}#messenger#messageTemplates`, diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts index ba74f806c3..fccaab1979 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts @@ -1,16 +1,14 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" import { - integrationMessengerModel, - messengerMessageTemplateModel, -} from "@chatbotx.io/database/schema" + buildContext, + messengerIntegrationService, +} from "@chatbotx.io/business" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { SdkException } from "@chatbotx.io/sdk" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -66,68 +64,10 @@ export async function syncMessengerMessageTemplatesForIntegration({ return true }) - await db.transaction(async (tx) => { - if (!isPartialSync) { - const existingTemplates = await tx - .select({ - id: messengerMessageTemplateModel.id, - sourceId: messengerMessageTemplateModel.sourceId, - }) - .from(messengerMessageTemplateModel) - .where( - eq( - messengerMessageTemplateModel.integrationMessengerId, - integrationMessenger.id, - ), - ) - - const incomingSourceIds = new Set(templates.map((t) => t.id)) - - const templatesToDelete = existingTemplates.filter( - (t) => !incomingSourceIds.has(t.sourceId), - ) - - if (templatesToDelete.length > 0) { - await tx.delete(messengerMessageTemplateModel).where( - inArray( - messengerMessageTemplateModel.id, - templatesToDelete.map((t) => t.id), - ), - ) - } - } - - for (const template of templates) { - await tx - .insert(messengerMessageTemplateModel) - .values([ - { - id: createId(), - name: template.name, - integrationMessengerId: integrationMessenger.id, - language: template.language, - category: template.category, - status: template.status, - parameterFormat: template.parameter_format ?? "POSITIONAL", - sourceId: template.id, - components: template.components, - }, - ]) - .onConflictDoUpdate({ - target: [ - messengerMessageTemplateModel.integrationMessengerId, - messengerMessageTemplateModel.sourceId, - ], - set: { - name: template.name, - language: template.language, - category: template.category, - status: template.status, - parameterFormat: template.parameter_format ?? "POSITIONAL", - components: template.components, - }, - }) - } + await messengerIntegrationService.syncMessageTemplates({ + integrationMessengerId: integrationMessenger.id, + templates, + isPartialSync, }) } @@ -138,14 +78,15 @@ export const syncMessengerMessageTemplateAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationMessenger = await findOrFail({ - table: integrationMessengerModel, - where: { + const integrationMessenger = + await messengerIntegrationService.findByIdForWorkspace({ workspaceId, id, - }, - message: "Messenger integration not found", - }) + }) + + if (!integrationMessenger) { + throw new Error("Messenger integration not found") + } await syncMessengerMessageTemplatesForIntegration({ workspaceId, diff --git a/apps/builder/src/features/integration-messenger/queries/index.ts b/apps/builder/src/features/integration-messenger/queries/index.ts index 2de7df45df..db234a3425 100644 --- a/apps/builder/src/features/integration-messenger/queries/index.ts +++ b/apps/builder/src/features/integration-messenger/queries/index.ts @@ -1,20 +1,27 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { messengerIntegrationService } from "@chatbotx.io/business" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" export const findIntegrationMessenger = async ( input: Partial>, -): Promise => - findOrFail({ table: integrationMessengerModel, where: input }) +): Promise => { + const integration = await messengerIntegrationService.findByIdForWorkspace({ + id: input.id as string, + workspaceId: input.workspaceId as string, + }) + + if (!integration) { + throw new Error("Integration Messenger not found") + } + + return integration +} export const listIntegrationMessengers = async ( input: Partial>, ): Promise<{ data: IntegrationMessengerModel[] }> => { - const data = await db.query.integrationMessengerModel.findMany({ - where: input, - orderBy: { - createdAt: "asc", - }, + const data = await messengerIntegrationService.listForWorkspace({ + workspaceId: input.workspaceId as string, + id: input.id, }) return { data } diff --git a/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts b/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts index 9ae8437bd9..e1dbd1309f 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts @@ -2,24 +2,12 @@ import { buildContext, - connectChannelIntegration, integrationWhatsappService, platformCredentialService, - workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { - db, - eq, - isUniqueViolationError, - type Transaction, -} from "@chatbotx.io/database/client" import type { WhatsappCredential } from "@chatbotx.io/database/partials" -import { - integrationWhatsappModel, - WHATSAPP_PHONE_NUMBER_UNIQUE_CONSTRAINT, -} from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, UserModel, @@ -196,32 +184,6 @@ async function ensurePhoneNumberNotConnected( } } -/** - * Spends the signup session inside the connect transaction, so the session and - * the integration it authorizes commit together. - * - * Losing the race here means a concurrent request already connected this - * number; rolling back leaves nothing half-written. - */ -async function spendSignupSession( - claim: SignupSessionClaim | undefined, - tx: Transaction, - messages: ConnectErrorMessages, -): Promise { - if (!claim) { - return - } - - const consumed = await integrationWhatsappService.consumeSignupSession({ - ...claim, - tx, - }) - - if (!consumed) { - throw new ChatbotXException(messages.signupSessionExpired) - } -} - function toPhoneNumberOption( phoneNumber: WhatsappPhoneNumber, ): WhatsappPhoneNumberOption { @@ -526,8 +488,17 @@ async function setupOAuthResources( } } -async function persistIntegration(params: { - tx: Transaction +/** + * Writes the integration and spends the signup session as one unit + * (`integrationWhatsappService.connectPhoneNumber`). + * + * `ensurePhoneNumberNotConnected` ran several network calls ago, so a + * concurrent connect can still reach the insert first. The service maps the + * resulting unique-constraint violation to a typed exception; this wrapper + * maps that typed code back onto the already-translated message. + */ +async function connectInTransaction(params: { + signupSessionClaim?: SignupSessionClaim ownerId: string userId: string workspaceId: string | null | undefined @@ -545,125 +516,39 @@ async function persistIntegration(params: { integrationRow: IntegrationWhatsappModel wasCreated: boolean }> { - const { - tx, - ownerId, - userId, - workspaceId, - integrationId, - phoneNumber, - wabaId, - businessId, - auth, - isCoexist, - platformType, - messages, - } = params - - let resolvedWorkspaceId = workspaceId - let createdWorkspace = false - - if (!resolvedWorkspaceId) { - const workspace = await workspaceService.create({ - tx, - createdBy: userId, - data: { - name: phoneNumber.verified_name, - timezone: "UTC", - ownerId: userId, - }, - }) - resolvedWorkspaceId = workspace.id - createdWorkspace = true - } + const { messages, ...connectParams } = params const displayPhoneNumber = normalizeWhatsappDisplayPhoneNumber( - phoneNumber.display_phone_number, + connectParams.phoneNumber.display_phone_number, ) - const phoneName = phoneNumber.verified_name.trim() || displayPhoneNumber - - let integrationRow: IntegrationWhatsappModel | undefined - - const { wasCreated } = await connectChannelIntegration({ - tx, - ownerId, - inboxData: { - id: createId(), - workspaceId: resolvedWorkspaceId, - channel: "whatsapp", - sourceId: phoneNumber.id, - name: phoneName, - }, - insertIntegration: async (inboxId) => { - const [row] = await tx - .insert(integrationWhatsappModel) - .values({ - id: integrationId, - workspaceId: resolvedWorkspaceId as string, - inboxId, - auth, - phoneNumberId: phoneNumber.id, - wabaId, - businessId, - name: phoneName, - displayPhoneNumber, - isCoexist, - platformType, - registrationStatus: "pending_verification", - }) - .onConflictDoUpdate({ - target: [integrationWhatsappModel.inboxId], - set: { - name: phoneName, - displayPhoneNumber, - isCoexist, - platformType, - updatedAt: new Date(), - }, - }) - .returning() - integrationRow = row - }, - }) - - if (!integrationRow) { - throw new ChatbotXException(messages.failedToPersistIntegration) - } - - return { - workspaceId: resolvedWorkspaceId, - createdWorkspace, - integrationRow, - wasCreated, - } -} - -/** - * Writes the integration and spends the signup session as one unit. - * - * `ensurePhoneNumberNotConnected` ran several network calls ago, so a - * concurrent connect can still reach the insert first. The unique index turns - * that into a constraint violation, which is the same user-visible situation - * the pre-flight check reports. - */ -async function connectInTransaction( - params: Omit[0], "tx" | "messages"> & { - signupSessionClaim?: SignupSessionClaim - messages: ConnectErrorMessages - }, -): Promise>> { - const { signupSessionClaim, messages, ...persistParams } = params + const phoneName = + connectParams.phoneNumber.verified_name.trim() || displayPhoneNumber try { - return await db.transaction(async (tx) => { - await spendSignupSession(signupSessionClaim, tx, messages) - - return persistIntegration({ tx, ...persistParams, messages }) + return await integrationWhatsappService.connectPhoneNumber({ + ...connectParams, + displayPhoneNumber, + phoneName, }) } catch (err) { - if (isUniqueViolationError(err, WHATSAPP_PHONE_NUMBER_UNIQUE_CONSTRAINT)) { + if ( + err instanceof ChatbotXException && + err.code === "whatsappSignupSessionExpired" + ) { + throw new ChatbotXException(messages.signupSessionExpired) + } + if ( + err instanceof ChatbotXException && + err.code === "whatsappPhoneNumberAlreadyConnected" + ) { throw new ChatbotXException(messages.phoneNumberAlreadyConnected) } + if ( + err instanceof ChatbotXException && + err.code === "whatsappFailedToPersistIntegration" + ) { + throw new ChatbotXException(messages.failedToPersistIntegration) + } throw err } @@ -676,15 +561,10 @@ async function subscribeManualWebhook( try { await subscribeWebhook({ auth, overrideCallbackUrl: true }) - await db - .update(integrationWhatsappModel) - .set({ - auth: { - ...auth, - metadata: { ...auth.metadata, subscribeOverrideOk: true }, - }, - }) - .where(eq(integrationWhatsappModel.id, integrationId)) + await integrationWhatsappService.markWebhookOverrideOk({ + id: integrationId, + auth, + }) logger.info("subscribeWebhook") } catch (err) { diff --git a/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts b/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts index 45d99c1dc3..071f78319b 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts @@ -1,14 +1,10 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" import { - coexistSyncRunModel, - integrationWhatsappModel, - whatsappCoexistStagingModel, -} from "@chatbotx.io/database/schema" + integrationWhatsappService, + workspaceService, +} from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" import { isRevokedTokenError } from "@chatbotx.io/integration-whatsapp" import { @@ -27,17 +23,17 @@ export const disconnectWhatsappAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationWhatsapp, workspace] = await Promise.all([ - findOrFail({ - table: integrationWhatsappModel, - where: { - workspaceId, - id, - }, - message: "Integration Whatsapp not found", + integrationWhatsappService.findByIdForWorkspace({ + workspaceId, + id, }), workspaceService.findById({ id: workspaceId }), ]) + if (!integrationWhatsapp) { + throw new Error("Integration Whatsapp not found") + } + try { await integrations.whatsapp.disconnect( integrationWhatsapp.auth as WhatsappAuthValue, @@ -48,56 +44,12 @@ export const disconnectWhatsappAction = workspaceActionClientAllowExpired } } - await db.transaction(async (tx) => { - // Preserve sync history (importedCount / lastSyncedAt / etc.) for - // audit and so reconnect can resume from prior watermark. Only abandon - // ACTIVE runs so the scheduler stops trying to drive them forward - // against a now-missing integration. - await tx - .update(coexistSyncRunModel) - .set({ - status: "failed", - finishedAt: new Date(), - currentError: "Integration disconnected", - }) - .where( - and( - eq(coexistSyncRunModel.integrationId, integrationWhatsapp.id), - inArray(coexistSyncRunModel.status, ["init", "running"]), - ), - ) - - await tx - .delete(whatsappCoexistStagingModel) - .where( - eq( - whatsappCoexistStagingModel.phoneNumberId, - integrationWhatsapp.phoneNumberId, - ), - ) - - // Polymorphic FK cleanup — no DB-level cascade for - // MetaCapiEvent.integrationId; stale rows would keep occupying the - // (workspaceId, channel, sourceKey) dedup slot after a reconnect. - await metaCapiEventRepository.deleteByIntegration( - { - workspaceId, - channel: "whatsapp", - integrationId: integrationWhatsapp.id, - }, - tx, - ) - - await tx - .delete(integrationWhatsappModel) - .where(eq(integrationWhatsappModel.id, integrationWhatsapp.id)) - - await inboxService.disconnect({ - inboxId: integrationWhatsapp.inboxId, - ownerId: workspace.ownerId, - workspaceId, - tx, - }) + await integrationWhatsappService.deleteWithCleanup({ + workspaceId, + id: integrationWhatsapp.id, + phoneNumberId: integrationWhatsapp.phoneNumberId, + inboxId: integrationWhatsapp.inboxId, + ownerId: workspace.ownerId, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts b/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts index 2b8ace1049..73e468a00c 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts @@ -1,13 +1,9 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { - integrationWhatsappModel, - whatsappFlowModel, -} from "@chatbotx.io/database/schema" +import { buildContext, integrationWhatsappService } from "@chatbotx.io/business" +import { whatsappFlowRepository } from "@chatbotx.io/database/repositories" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,14 +14,15 @@ export const syncWhatsappFlowsAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id, - }, - message: "Whatsapp integration not found", - }) + }) + + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -41,59 +38,8 @@ export const syncWhatsappFlowsAction = workspaceActionClient params: { limit: 100 }, }) - await db.transaction(async (tx) => { - const existingFlows = await tx - .select({ - id: whatsappFlowModel.id, - sourceId: whatsappFlowModel.sourceId, - }) - .from(whatsappFlowModel) - .where( - eq(whatsappFlowModel.integrationWhatsappId, integrationWhatsapp.id), - ) - - const incomingSourceIds = new Set(res.data.map((f) => f.id)) - - const flowsToDelete = existingFlows.filter( - (f) => !incomingSourceIds.has(f.sourceId), - ) - - if (flowsToDelete.length > 0) { - await tx.delete(whatsappFlowModel).where( - inArray( - whatsappFlowModel.id, - flowsToDelete.map((f) => f.id), - ), - ) - } - - for (const flow of res.data) { - const existing = existingFlows.find((f) => f.sourceId === flow.id) - - if (existing) { - await tx - .update(whatsappFlowModel) - .set({ - name: flow.name, - status: flow.status, - categories: flow.categories, - validationErrors: flow.validation_errors, - }) - .where(eq(whatsappFlowModel.id, existing.id)) - } else { - await tx.insert(whatsappFlowModel).values([ - { - id: createId(), - name: flow.name, - integrationWhatsappId: integrationWhatsapp.id, - sourceId: flow.id, - status: flow.status, - categories: flow.categories, - validationErrors: flow.validation_errors, - completedCount: "0", - }, - ]) - } - } + await whatsappFlowRepository.syncForIntegration({ + integrationWhatsappId: integrationWhatsapp.id, + flows: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts index dcc2ba6e90..4639e15e97 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts @@ -1,13 +1,9 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { - integrationWhatsappModel, - whatsappMessageTemplateModel, -} from "@chatbotx.io/database/schema" +import { buildContext, integrationWhatsappService } from "@chatbotx.io/business" +import { whatsappMessageTemplateRepository } from "@chatbotx.io/database/repositories" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,14 +14,15 @@ export const syncMessageTemplateAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id, - }, - message: "Whatsapp integration not found", - }) + }) + + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -39,65 +36,8 @@ export const syncMessageTemplateAction = workspaceActionClient ctx, }) - await db.transaction(async (tx) => { - const existingTemplates = await tx - .select({ - id: whatsappMessageTemplateModel.id, - sourceId: whatsappMessageTemplateModel.sourceId, - }) - .from(whatsappMessageTemplateModel) - .where( - eq( - whatsappMessageTemplateModel.integrationWhatsappId, - integrationWhatsapp.id, - ), - ) - - const incomingSourceIds = new Set(res.data.map((t) => t.id)) - - const templatesToDelete = existingTemplates.filter( - (t) => !incomingSourceIds.has(t.sourceId), - ) - - if (templatesToDelete.length > 0) { - await tx.delete(whatsappMessageTemplateModel).where( - inArray( - whatsappMessageTemplateModel.id, - templatesToDelete.map((t) => t.id), - ), - ) - } - - for (const template of res.data) { - const existing = existingTemplates.find( - (t) => t.sourceId === template.id, - ) - - if (existing) { - await tx - .update(whatsappMessageTemplateModel) - .set({ - name: template.name, - language: template.language, - category: template.category, - status: template.status, - components: template.components, - }) - .where(eq(whatsappMessageTemplateModel.id, existing.id)) - } else { - await tx.insert(whatsappMessageTemplateModel).values([ - { - id: createId(), - name: template.name, - integrationWhatsappId: integrationWhatsapp.id, - language: template.language, - category: template.category, - status: template.status, - sourceId: template.id, - components: template.components, - }, - ]) - } - } + await whatsappMessageTemplateRepository.syncForIntegration({ + integrationWhatsappId: integrationWhatsapp.id, + templates: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/queries/index.ts b/apps/builder/src/features/integration-whatsapp/queries/index.ts index 35c331e41c..7b0ce2c88f 100644 --- a/apps/builder/src/features/integration-whatsapp/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/queries/index.ts @@ -1,6 +1,6 @@ import type { IntegrationWhatsappResource } from "@chatbotx.io/business" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationWhatsappModel } from "@chatbotx.io/database/schema" +import { integrationWhatsappService } from "@chatbotx.io/business" +import { integrationWhatsappRepository } from "@chatbotx.io/database/repositories" import type { InboxModel, IntegrationWhatsappModel, @@ -12,44 +12,11 @@ type IntegrationWhatsappWithInbox = IntegrationWhatsappResource & { inbox?: Pick } -// Explicit allowlist mirroring `integrationWhatsappResource` (see -// packages/business/src/integration-whatsapp/schema.ts) so the encrypted -// `auth` and `capiAccessToken` columns can never reach this client-facing -// list, even at runtime. Keep this in sync with that pick() when either -// changes. -const CLIENT_SAFE_COLUMNS = { - id: true, - name: true, - inboxId: true, - displayPhoneNumber: true, - tokenRefreshError: true, - phoneNumberId: true, - wabaId: true, - hasCapiScope: true, - capiScopeCheckedAt: true, - datasetId: true, - workspaceId: true, - createdAt: true, -} as const - export const listIntegrationWhatsapps = async ( props: Pick, ): Promise> => { - const data = await db.query.integrationWhatsappModel.findMany({ - where: props, - columns: CLIENT_SAFE_COLUMNS, - orderBy: { - createdAt: "asc", - }, - with: { - inbox: { - columns: { - id: true, - name: true, - }, - }, - }, - }) + const data = + await integrationWhatsappRepository.listClientSafeByWorkspaceId(props) return { data, pageCount: 1 } } @@ -62,12 +29,16 @@ export const listIntegrationWhatsapps = async ( // (see `IntegrationWhatsappLinkable` / `toIntegrationWhatsappLinkable` below). export const findIntegrationWhatsapp = async ( props: Pick, -): Promise => - await findOrFail({ - table: integrationWhatsappModel, - where: props, - message: "Whatsapp integration not found", - }) +): Promise => { + const integration = + await integrationWhatsappService.findByIdForWorkspace(props) + + if (!integration) { + throw new Error("Whatsapp integration not found") + } + + return integration +} // Safe, non-secret subset of IntegrationWhatsappModel for client components // that only need to build Meta "manage" deep links / identify the @@ -89,28 +60,15 @@ export const toIntegrationWhatsappLinkable = ( export const findIntegrationWhatsappById = async ( id: IntegrationWhatsappModel["id"], -): Promise => { - const integration = await db.query.integrationWhatsappModel.findFirst({ - where: { id }, - }) - - return integration ?? null -} +): Promise => + await integrationWhatsappRepository.findById({ id }) export const markWhatsappWebhookVerified = async ( id: IntegrationWhatsappModel["id"], current: WhatsappAuthValue, ): Promise => { - const updatedAuth: WhatsappAuthValue = { - ...current, - metadata: { - ...current.metadata, - webhookVerifiedAt: new Date().toISOString(), - }, - } - - await db - .update(integrationWhatsappModel) - .set({ auth: updatedAuth }) - .where(eq(integrationWhatsappModel.id, id)) + await integrationWhatsappService.markWebhookVerified({ + id, + auth: current, + }) } diff --git a/packages/business/__tests__/instagram-integration-disconnect.service.test.ts b/packages/business/__tests__/instagram-integration-disconnect.service.test.ts new file mode 100644 index 0000000000..65886c3f94 --- /dev/null +++ b/packages/business/__tests__/instagram-integration-disconnect.service.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// deleteInstagramIntegrationWithCleanup — isFacebook: true skips coexist +// teardown (the Facebook-mediated variant never has coexist runs); false +// calls it. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + tearDownForIntegration: vi.fn(), + deleteByIntegration: vi.fn(), + deleteById: vi.fn(), + disconnect: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: mocks.transaction }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationInstagramRepository: { deleteById: mocks.deleteById }, + metaCapiEventRepository: { deleteByIntegration: mocks.deleteByIntegration }, +})) + +vi.mock("../src/coexist/service", () => ({ + coexistService: { tearDownForIntegration: mocks.tearDownForIntegration }, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mocks.disconnect }, +})) + +const { deleteInstagramIntegrationWithCleanup } = await import( + "../src/integration-instagram/disconnect" +) + +const tx = { tx: true } + +describe("deleteInstagramIntegrationWithCleanup", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => + await callback(tx), + ) + mocks.tearDownForIntegration.mockResolvedValue(undefined) + mocks.deleteByIntegration.mockResolvedValue(undefined) + mocks.deleteById.mockResolvedValue(undefined) + mocks.disconnect.mockResolvedValue(undefined) + }) + + test("isFacebook: false calls coexist teardown", async () => { + await deleteInstagramIntegrationWithCleanup({ + workspaceId: "ws_1", + id: "ig_1", + inboxId: "inbox_1", + ownerId: "owner_1", + isFacebook: false, + }) + + expect(mocks.tearDownForIntegration).toHaveBeenCalledWith({ + workspaceId: "ws_1", + integrationId: "ig_1", + channel: "instagram", + currentError: "Integration disconnected", + tx, + }) + }) + + test("isFacebook: true skips coexist teardown", async () => { + await deleteInstagramIntegrationWithCleanup({ + workspaceId: "ws_1", + id: "ig_1", + inboxId: "inbox_1", + ownerId: "owner_1", + isFacebook: true, + }) + + expect(mocks.tearDownForIntegration).not.toHaveBeenCalled() + // The rest of the cleanup still runs regardless of channel type. + expect(mocks.deleteByIntegration).toHaveBeenCalledWith( + { workspaceId: "ws_1", channel: "instagram", integrationId: "ig_1" }, + tx, + ) + expect(mocks.deleteById).toHaveBeenCalledWith({ id: "ig_1" }, tx) + expect(mocks.disconnect).toHaveBeenCalledWith({ + inboxId: "inbox_1", + ownerId: "owner_1", + workspaceId: "ws_1", + tx, + }) + }) +}) diff --git a/packages/business/__tests__/integration-service-oauth-callback.test.ts b/packages/business/__tests__/integration-service-oauth-callback.test.ts new file mode 100644 index 0000000000..c031f5a3e1 --- /dev/null +++ b/packages/business/__tests__/integration-service-oauth-callback.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// integrationService.createFromOAuthCallback — the googleSheets branch +// inserts both the generic Integration row and the type-specific +// IntegrationGoogleSheets row in one transaction; other types insert only +// the Integration row. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + insert: vi.fn(), + createId: vi.fn(() => "integration-1"), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mocks.createId, +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn(), + db: { transaction: mocks.transaction }, + eq: vi.fn(), + exists: vi.fn(), + isNotNull: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + or: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationGoogleSheetsModel: { workspaceId: "workspaceId" }, + integrationInstagramModel: {}, + integrationMessengerModel: {}, + integrationMetaCatalogModel: {}, + integrationModel: { id: "id", workspaceId: "workspaceId" }, + integrationTiktokModel: {}, + integrationWhatsappModel: {}, + integrationZaloModel: {}, +})) + +const { integrationService } = await import("../src/integration/service") + +function mutationChain() { + const builder = { + values: vi.fn(() => Promise.resolve(undefined)), + } + return builder +} + +describe("integrationService.createFromOAuthCallback", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createId.mockReturnValue("integration-1") + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => { + const tx = { insert: mocks.insert } + return await callback(tx) + }, + ) + mocks.insert.mockReturnValue(mutationChain()) + }) + + test("googleSheets branch inserts both rows in one transaction", async () => { + const result = await integrationService.createFromOAuthCallback({ + workspaceId: "ws_1", + integrationType: "googleSheets", + googleSheetsAuth: { accessToken: "tok" } as never, + }) + + expect(result).toEqual({ integrationId: "integration-1" }) + expect(mocks.transaction).toHaveBeenCalledOnce() + expect(mocks.insert).toHaveBeenCalledTimes(2) + }) + + test("other integration types insert only the Integration row", async () => { + const result = await integrationService.createFromOAuthCallback({ + workspaceId: "ws_1", + integrationType: "zalo", + }) + + expect(result).toEqual({ integrationId: "integration-1" }) + expect(mocks.insert).toHaveBeenCalledTimes(1) + }) + + test("googleSheets without an auth value inserts only the Integration row", async () => { + await integrationService.createFromOAuthCallback({ + workspaceId: "ws_1", + integrationType: "googleSheets", + googleSheetsAuth: null, + }) + + expect(mocks.insert).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/business/__tests__/integration-whatsapp-connect-phone-number.service.test.ts b/packages/business/__tests__/integration-whatsapp-connect-phone-number.service.test.ts new file mode 100644 index 0000000000..3166efb307 --- /dev/null +++ b/packages/business/__tests__/integration-whatsapp-connect-phone-number.service.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// integrationWhatsappService.connectPhoneNumber — signup session consumed +// with the tx; a unique violation on the phone-number index maps to the +// typed exception; createdWorkspace is true only when no workspaceId was +// supplied. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + consumeSignupSession: vi.fn(), + createWorkspace: vi.fn(), + isConnected: vi.fn(), + createInbox: vi.fn(), + insert: vi.fn(), + createId: vi.fn(() => "new-id"), + isUniqueViolationError: vi.fn(() => false), + decryptText: vi.fn(() => Promise.resolve("decrypted-token")), +})) + +vi.mock("@chatbotx.io/utils", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createId: mocks.createId, + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: mocks.transaction }, + isUniqueViolationError: mocks.isUniqueViolationError, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + consumeSignupSession: mocks.consumeSignupSession, + }, + metaCapiEventRepository: {}, + whatsappCoexistStagingRepository: {}, +})) + +vi.mock("../src/workspace/service", () => ({ + workspaceService: { create: mocks.createWorkspace }, +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: vi.fn( + async ({ + insertIntegration, + }: { + tx: unknown + insertIntegration: (inboxId: string, wasCreated: boolean) => unknown + }) => { + const isConnected = await mocks.isConnected() + if (isConnected) { + throw new Error("channel already connected") + } + const wasCreated = true + const integration = await insertIntegration("inbox_new", wasCreated) + return { inbox: { id: "inbox_new" }, wasCreated, integration } + }, + ), +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: vi.fn() }, +})) + +vi.mock("../src/coexist/service", () => ({ + coexistService: { tearDownForIntegration: vi.fn() }, +})) + +vi.mock("@chatbotx.io/encryption", async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + encryptUtils: { + ...actual.encryptUtils, + decryptText: mocks.decryptText, + }, + } +}) + +const { integrationWhatsappService } = await import( + "../src/integration-whatsapp/service" +) + +const phoneNumber = { + id: "phone_1", + verified_name: "Acme Support", + display_phone_number: "+1 555 0100", +} + +describe("integrationWhatsappService.connectPhoneNumber", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isUniqueViolationError.mockReturnValue(false) + mocks.isConnected.mockResolvedValue(false) + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => { + const tx = { + insert: mocks.insert, + } + return await callback(tx) + }, + ) + mocks.insert.mockReturnValue({ + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), + returning: vi.fn(() => + Promise.resolve([ + { id: "iw_1", workspaceId: "ws_created", phoneNumberId: "phone_1" }, + ]), + ), + }) + }) + + test("spends the signup session claim with the connect tx", async () => { + mocks.consumeSignupSession.mockResolvedValue({ + id: "session_1", + encryptedAccessToken: { + v: 1, + iv: "0".repeat(24), + text: "encrypted-text", + tag: "0".repeat(32), + }, + }) + mocks.createWorkspace.mockResolvedValue({ id: "ws_created" }) + + await integrationWhatsappService.connectPhoneNumber({ + signupSessionClaim: { + id: "session_1", + userId: "user_1", + ownerId: "owner_1", + phoneNumberId: "phone_1", + }, + ownerId: "owner_1", + userId: "user_1", + integrationId: "iw_1", + phoneNumber, + displayPhoneNumber: "+15550100", + phoneName: "Acme Support", + wabaId: "waba_1", + businessId: "biz_1", + auth: { tokens: { accessToken: "tok" } }, + isCoexist: false, + platformType: "CLOUD_API", + }) + + expect(mocks.consumeSignupSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "session_1", tx: expect.anything() }), + ) + }) + + test("createdWorkspace is true only when no workspaceId was supplied", async () => { + mocks.createWorkspace.mockResolvedValue({ id: "ws_created" }) + + const result = await integrationWhatsappService.connectPhoneNumber({ + ownerId: "owner_1", + userId: "user_1", + integrationId: "iw_1", + phoneNumber, + displayPhoneNumber: "+15550100", + phoneName: "Acme Support", + wabaId: "waba_1", + businessId: "biz_1", + auth: {}, + isCoexist: false, + platformType: "CLOUD_API", + }) + + expect(result.createdWorkspace).toBe(true) + expect(mocks.createWorkspace).toHaveBeenCalledOnce() + }) + + test("does not create a workspace when workspaceId is supplied", async () => { + const result = await integrationWhatsappService.connectPhoneNumber({ + ownerId: "owner_1", + userId: "user_1", + workspaceId: "ws_existing", + integrationId: "iw_1", + phoneNumber, + displayPhoneNumber: "+15550100", + phoneName: "Acme Support", + wabaId: "waba_1", + businessId: "biz_1", + auth: {}, + isCoexist: false, + platformType: "CLOUD_API", + }) + + expect(result.createdWorkspace).toBe(false) + expect(mocks.createWorkspace).not.toHaveBeenCalled() + }) + + test("maps a unique-violation on the phone-number index to the typed exception", async () => { + mocks.isUniqueViolationError.mockReturnValue(true) + mocks.transaction.mockRejectedValueOnce(new Error("unique violation")) + + await expect( + integrationWhatsappService.connectPhoneNumber({ + ownerId: "owner_1", + userId: "user_1", + workspaceId: "ws_existing", + integrationId: "iw_1", + phoneNumber, + displayPhoneNumber: "+15550100", + phoneName: "Acme Support", + wabaId: "waba_1", + businessId: "biz_1", + auth: {}, + isCoexist: false, + platformType: "CLOUD_API", + }), + ).rejects.toMatchObject({ code: "whatsappPhoneNumberAlreadyConnected" }) + }) +}) diff --git a/packages/business/__tests__/integration-whatsapp-disconnect.service.test.ts b/packages/business/__tests__/integration-whatsapp-disconnect.service.test.ts new file mode 100644 index 0000000000..20311f44ad --- /dev/null +++ b/packages/business/__tests__/integration-whatsapp-disconnect.service.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// integrationWhatsappService.deleteWithCleanup — staging delete uses +// phoneNumberId; the service never emits an audit record (that stays in the +// builder action). +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + tearDownForIntegration: vi.fn(), + deleteByPhoneNumberId: vi.fn(), + deleteByIntegration: vi.fn(), + deleteById: vi.fn(), + disconnect: vi.fn(), + auditRecord: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: mocks.transaction }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { deleteById: mocks.deleteById }, + metaCapiEventRepository: { deleteByIntegration: mocks.deleteByIntegration }, + whatsappCoexistStagingRepository: { + deleteByPhoneNumberId: mocks.deleteByPhoneNumberId, + }, +})) + +vi.mock("../src/coexist/service", () => ({ + coexistService: { tearDownForIntegration: mocks.tearDownForIntegration }, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mocks.disconnect }, +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mocks.auditRecord, +})) + +const { integrationWhatsappService } = await import( + "../src/integration-whatsapp/service" +) + +const tx = { tx: true } + +describe("integrationWhatsappService.deleteWithCleanup", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => + await callback(tx), + ) + mocks.tearDownForIntegration.mockResolvedValue(undefined) + mocks.deleteByPhoneNumberId.mockResolvedValue(undefined) + mocks.deleteByIntegration.mockResolvedValue(undefined) + mocks.deleteById.mockResolvedValue(undefined) + mocks.disconnect.mockResolvedValue(undefined) + }) + + test("scopes the coexist teardown to channel: whatsapp", async () => { + await integrationWhatsappService.deleteWithCleanup({ + workspaceId: "ws_1", + id: "iw_1", + phoneNumberId: "phone_1", + inboxId: "inbox_1", + ownerId: "owner_1", + }) + + expect(mocks.tearDownForIntegration).toHaveBeenCalledWith({ + workspaceId: "ws_1", + integrationId: "iw_1", + channel: "whatsapp", + currentError: "Integration disconnected", + tx, + }) + }) + + test("deletes coexist staging rows scoped by phoneNumberId", async () => { + await integrationWhatsappService.deleteWithCleanup({ + workspaceId: "ws_1", + id: "iw_1", + phoneNumberId: "phone_1", + inboxId: "inbox_1", + ownerId: "owner_1", + }) + + expect(mocks.deleteByPhoneNumberId).toHaveBeenCalledWith( + { phoneNumberId: "phone_1" }, + tx, + ) + }) + + test("never emits an audit record — that stays in the builder action", async () => { + await integrationWhatsappService.deleteWithCleanup({ + workspaceId: "ws_1", + id: "iw_1", + phoneNumberId: "phone_1", + inboxId: "inbox_1", + ownerId: "owner_1", + }) + + expect(mocks.auditRecord).not.toHaveBeenCalled() + }) + + test("cleans up the integration row and disconnects the inbox", async () => { + await integrationWhatsappService.deleteWithCleanup({ + workspaceId: "ws_1", + id: "iw_1", + phoneNumberId: "phone_1", + inboxId: "inbox_1", + ownerId: "owner_1", + }) + + expect(mocks.deleteByIntegration).toHaveBeenCalledWith( + { workspaceId: "ws_1", channel: "whatsapp", integrationId: "iw_1" }, + tx, + ) + expect(mocks.deleteById).toHaveBeenCalledWith({ id: "iw_1" }, tx) + expect(mocks.disconnect).toHaveBeenCalledWith({ + inboxId: "inbox_1", + ownerId: "owner_1", + workspaceId: "ws_1", + tx, + }) + }) +}) diff --git a/packages/business/__tests__/messenger-integration-disconnect.service.test.ts b/packages/business/__tests__/messenger-integration-disconnect.service.test.ts new file mode 100644 index 0000000000..d3aa2cba2e --- /dev/null +++ b/packages/business/__tests__/messenger-integration-disconnect.service.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// deleteMessengerIntegrationWithCleanup — the transactional cleanup body +// extracted from disconnect-messenger.ts: teardown -> tagChannel delete -> +// capi delete -> integration delete -> inbox disconnect, in order, all under +// the same tx. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + tearDownForIntegration: vi.fn(), + deleteByIntegration: vi.fn(), + deleteById: vi.fn(), + disconnect: vi.fn(), + del: vi.fn(), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: mocks.and, + db: { transaction: mocks.transaction }, + eq: mocks.eq, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { messenger: "messenger" } }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationMessengerRepository: { deleteById: mocks.deleteById }, + metaCapiEventRepository: { deleteByIntegration: mocks.deleteByIntegration }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + tagChannelModel: { + channelType: "channelType", + integrationId: "integrationId", + }, +})) + +vi.mock("../src/coexist/service", () => ({ + coexistService: { tearDownForIntegration: mocks.tearDownForIntegration }, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mocks.disconnect }, +})) + +const { deleteMessengerIntegrationWithCleanup } = await import( + "../src/integration-messenger/disconnect" +) + +const tx = { tx: true } + +describe("deleteMessengerIntegrationWithCleanup", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => + await callback(tx), + ) + mocks.del.mockReturnValue({ + where: vi.fn(() => Promise.resolve(undefined)), + }) + }) + + test("runs teardown, tagChannel delete, capi delete, integration delete, then inbox disconnect in order under the same tx", async () => { + const callOrder: string[] = [] + mocks.tearDownForIntegration.mockImplementation(() => { + callOrder.push("tearDown") + return Promise.resolve(undefined) + }) + mocks.deleteByIntegration.mockImplementation(() => { + callOrder.push("capiDelete") + return Promise.resolve(undefined) + }) + mocks.deleteById.mockImplementation(() => { + callOrder.push("integrationDelete") + return Promise.resolve(undefined) + }) + mocks.disconnect.mockImplementation(() => { + callOrder.push("inboxDisconnect") + return Promise.resolve(undefined) + }) + + const deleteBuilder = { + where: vi.fn(() => { + callOrder.push("tagChannelDelete") + return Promise.resolve(undefined) + }), + } + const txWithDelete = { ...tx, delete: vi.fn(() => deleteBuilder) } + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => + await callback(txWithDelete), + ) + + await deleteMessengerIntegrationWithCleanup({ + workspaceId: "ws_1", + id: "im_1", + inboxId: "inbox_1", + ownerId: "owner_1", + }) + + expect(callOrder).toEqual([ + "tearDown", + "tagChannelDelete", + "capiDelete", + "integrationDelete", + "inboxDisconnect", + ]) + + expect(mocks.tearDownForIntegration).toHaveBeenCalledWith({ + workspaceId: "ws_1", + integrationId: "im_1", + channel: "messenger", + currentError: "Integration disconnected", + tx: txWithDelete, + }) + expect(mocks.deleteByIntegration).toHaveBeenCalledWith( + { workspaceId: "ws_1", channel: "messenger", integrationId: "im_1" }, + txWithDelete, + ) + expect(mocks.deleteById).toHaveBeenCalledWith({ id: "im_1" }, txWithDelete) + expect(mocks.disconnect).toHaveBeenCalledWith({ + inboxId: "inbox_1", + ownerId: "owner_1", + workspaceId: "ws_1", + tx: txWithDelete, + }) + }) + + test("uses the caller-supplied tx instead of opening its own transaction", async () => { + mocks.tearDownForIntegration.mockResolvedValue(undefined) + mocks.deleteByIntegration.mockResolvedValue(undefined) + mocks.deleteById.mockResolvedValue(undefined) + mocks.disconnect.mockResolvedValue(undefined) + + const callerTx = { + delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })), + } + + await deleteMessengerIntegrationWithCleanup({ + workspaceId: "ws_1", + id: "im_1", + inboxId: "inbox_1", + ownerId: "owner_1", + tx: callerTx as never, + }) + + expect(mocks.transaction).not.toHaveBeenCalled() + expect(mocks.tearDownForIntegration).toHaveBeenCalledWith( + expect.objectContaining({ tx: callerTx }), + ) + }) +}) diff --git a/packages/business/__tests__/messenger-message-templates-sync.service.test.ts b/packages/business/__tests__/messenger-message-templates-sync.service.test.ts new file mode 100644 index 0000000000..a2fd2ac3c7 --- /dev/null +++ b/packages/business/__tests__/messenger-message-templates-sync.service.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// messengerIntegrationService.syncMessageTemplates — full sync deletes rows +// no longer present upstream; partial sync (single created/cloned template) +// never deletes. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + select: vi.fn(), + del: vi.fn(), + insert: vi.fn(), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + inArray: [column, values], + })), + createId: vi.fn(() => "new-id"), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mocks.createId, +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: mocks.and, + db: { transaction: mocks.transaction }, + eq: mocks.eq, + findOrFail: vi.fn(), + inArray: mocks.inArray, + sql: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationMessengerRepository: {}, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationMessengerModel: { + id: "id", + workspaceId: "workspaceId", + pageId: "pageId", + auth: "auth", + }, + messengerMessageTemplateModel: { + id: "id", + sourceId: "sourceId", + integrationMessengerId: "integrationMessengerId", + name: "name", + language: "language", + category: "category", + status: "status", + parameterFormat: "parameterFormat", + components: "components", + }, +})) + +const { messengerIntegrationService } = await import( + "../src/integration-messenger/service" +) + +function selectChain(finalResult: unknown[]) { + return { + from: vi.fn().mockReturnThis(), + where: vi.fn(() => Promise.resolve(finalResult)), + } +} + +function mutationChain() { + const builder = { + values: vi.fn(() => builder), + onConflictDoUpdate: vi.fn(() => Promise.resolve(undefined)), + where: vi.fn(() => Promise.resolve(undefined)), + } + return builder +} + +describe("messengerIntegrationService.syncMessageTemplates", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createId.mockReturnValue("new-id") + mocks.transaction.mockImplementation( + async (callback: (client: unknown) => Promise) => { + const tx = { + select: mocks.select, + delete: mocks.del, + insert: mocks.insert, + } + return await callback(tx) + }, + ) + }) + + test("full sync deletes exactly the non-incoming ids", async () => { + mocks.select.mockReturnValue( + selectChain([ + { id: "tpl_stale", sourceId: "src_stale" }, + { id: "tpl_keep", sourceId: "src_keep" }, + ]), + ) + const deleteBuilder = { where: vi.fn(() => Promise.resolve(undefined)) } + mocks.del.mockReturnValue(deleteBuilder) + mocks.insert.mockReturnValue(mutationChain()) + + await messengerIntegrationService.syncMessageTemplates({ + integrationMessengerId: "im_1", + isPartialSync: false, + templates: [ + { + id: "src_keep", + name: "Keep", + language: "en_US", + category: "UTILITY", + status: "APPROVED", + components: [], + }, + ], + }) + + expect(mocks.del).toHaveBeenCalledOnce() + expect(mocks.inArray).toHaveBeenCalledWith("id", ["tpl_stale"]) + }) + + test("partial sync skips the stale-delete", async () => { + mocks.select.mockReturnValue(selectChain([])) + mocks.insert.mockReturnValue(mutationChain()) + + await messengerIntegrationService.syncMessageTemplates({ + integrationMessengerId: "im_1", + isPartialSync: true, + templates: [ + { + id: "src_new", + name: "New", + language: "en_US", + category: "UTILITY", + status: "APPROVED", + components: [], + }, + ], + }) + + expect(mocks.del).not.toHaveBeenCalled() + // select is still not needed for a partial sync's stale-delete path. + expect(mocks.select).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/src/integration-instagram/connect-account.ts b/packages/business/src/integration-instagram/connect-account.ts new file mode 100644 index 0000000000..9287659e42 --- /dev/null +++ b/packages/business/src/integration-instagram/connect-account.ts @@ -0,0 +1,109 @@ +import { db } from "@chatbotx.io/database/client" +import { integrationInstagramModel } from "@chatbotx.io/database/schema" +import type { IntegrationInstagramModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { connectChannelIntegration } from "../inbox/connect-channel" +import { resolveTenantSettings } from "../platform/settings" +import { workspaceService } from "../workspace/service" + +type InstagramPersistentMenu = { + label: string + type: "url" + url: string +} + +/** + * DB-only body of both Instagram account-select connects (native login and + * Facebook-mediated): optionally creates the workspace, resolves the + * tenant's `appUrl`, then inserts the `IntegrationInstagram` row via + * `connectChannelIntegration`. All Meta API calls (webhook subscribe, + * branding) stay in the builder action — `packages/business` has no + * dependency on `@chatbotx.io/integration-instagram(-facebook)`. + * + * `buildPersistentMenus` is a pure callback (no external deps) so the + * branding-menu shape — which needs `getBrandingUrl` from a builder-only + * feature module — can still be built with the `appUrl` resolved inside this + * transaction. + */ +export async function connectInstagramAccount(input: { + ownerId: string + userId: string + workspaceId?: string | null + igId: string + igName: string + igUsername: string + pageId: string + auth: unknown + type?: "facebook" + buildPersistentMenus: (appUrl: string) => InstagramPersistentMenu[] +}): Promise<{ + workspaceId: string + createdWorkspace: boolean + integrationRow: IntegrationInstagramModel + wasCreated: boolean + appUrl: string +}> { + return await db.transaction(async (tx) => { + let workspaceId = input.workspaceId + let createdWorkspace = false + + if (!workspaceId) { + const workspace = await workspaceService.create({ + tx, + createdBy: input.userId, + data: { + name: input.igName, + timezone: "UTC", + ownerId: input.userId, + }, + }) + workspaceId = workspace.id + createdWorkspace = true + } + + const { appUrl } = await resolveTenantSettings({ workspaceId, tx }) + + const { integration: integrationRow, wasCreated } = + await connectChannelIntegration({ + tx, + ownerId: input.ownerId, + inboxData: { + id: createId(), + workspaceId, + name: input.igName, + channel: "instagram", + sourceId: input.igId, + }, + insertIntegration: async (inboxId) => + tx + .insert(integrationInstagramModel) + .values({ + id: createId(), + workspaceId: workspaceId as string, + inboxId, + igId: input.igId, + pageId: input.pageId, + auth: input.auth, + name: input.igName, + username: input.igUsername, + ...(input.type ? { type: input.type } : {}), + persistentMenus: input.buildPersistentMenus(appUrl), + conversationStarters: [], + }) + .returning() + .then((result) => result[0]), + }) + + if (!integrationRow) { + throw new Error("Failed to create Instagram integration") + } + + return { + workspaceId, + createdWorkspace, + integrationRow, + wasCreated, + appUrl, + } + }) +} diff --git a/packages/business/src/integration-instagram/disconnect.ts b/packages/business/src/integration-instagram/disconnect.ts new file mode 100644 index 0000000000..cb8421136d --- /dev/null +++ b/packages/business/src/integration-instagram/disconnect.ts @@ -0,0 +1,69 @@ +import type { DatabaseClient } from "@chatbotx.io/database/client" +import { db } from "@chatbotx.io/database/client" +import { + integrationInstagramRepository, + metaCapiEventRepository, +} from "@chatbotx.io/database/repositories" +import { coexistService } from "../coexist/service" +import { inboxService } from "../inbox/service" + +/** + * Transactional cleanup for an Instagram disconnect: coexist teardown + * (native Instagram only — the Facebook-mediated variant never has coexist + * runs), the polymorphic MetaCapiEvent rows, the integration row itself, and + * the owning inbox. The external Meta-API disconnect call and the audit + * record stay in the builder action (see disconnect-instagram.ts). + * + * Kept in a sibling file (rather than a method on `InstagramIntegrationService`) + * so importing the service alone does not pull in `coexistService` / + * `inboxService` — that chain reaches `@chatbotx.io/analytics` via + * `quotaEnforcementService`, which is a much heavier module graph than the + * plain-query methods on the service need. + */ +export async function deleteInstagramIntegrationWithCleanup(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + isFacebook: boolean + tx?: DatabaseClient +}): Promise { + const run = async (tx: DatabaseClient) => { + if (!input.isFacebook) { + await coexistService.tearDownForIntegration({ + workspaceId: input.workspaceId, + integrationId: input.id, + channel: "instagram", + currentError: "Integration disconnected", + tx, + }) + } + + // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep occupying + // the (workspaceId, channel, sourceKey) dedup slot after a reconnect. + await metaCapiEventRepository.deleteByIntegration( + { + workspaceId: input.workspaceId, + channel: "instagram", + integrationId: input.id, + }, + tx, + ) + + await integrationInstagramRepository.deleteById({ id: input.id }, tx) + + await inboxService.disconnect({ + inboxId: input.inboxId, + ownerId: input.ownerId, + workspaceId: input.workspaceId, + tx, + }) + } + + if (input.tx) { + await run(input.tx) + return + } + + await db.transaction(run) +} diff --git a/packages/business/src/integration-instagram/index.ts b/packages/business/src/integration-instagram/index.ts index 5c5f459590..e9798dc9ff 100644 --- a/packages/business/src/integration-instagram/index.ts +++ b/packages/business/src/integration-instagram/index.ts @@ -1,2 +1,4 @@ +export * from "./connect-account" +export * from "./disconnect" export * from "./schema" export * from "./service" diff --git a/packages/business/src/integration-instagram/service.ts b/packages/business/src/integration-instagram/service.ts index c224d7434b..941a2e8545 100644 --- a/packages/business/src/integration-instagram/service.ts +++ b/packages/business/src/integration-instagram/service.ts @@ -1,5 +1,6 @@ import { and, db, eq, findOrFail, sql } from "@chatbotx.io/database/client" import type { IntegrationUserInfo } from "@chatbotx.io/database/partials" +import { integrationInstagramRepository } from "@chatbotx.io/database/repositories" import { integrationInstagramModel } from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" @@ -161,6 +162,26 @@ class InstagramIntegrationService extends BaseService { existsByPageId(pageId: string): Promise { return this.existsForPage({ pageId }) } + + async updateSettings(input: { + workspaceId: string + id: string + values: Partial + }): Promise { + await db + .update(integrationInstagramModel) + .set(input.values) + .where( + and( + eq(integrationInstagramModel.id, input.id), + eq(integrationInstagramModel.workspaceId, input.workspaceId), + ), + ) + } + + listForWorkspace(input: { workspaceId: string }) { + return integrationInstagramRepository.listForWorkspace(input) + } } export const instagramIntegrationService = new InstagramIntegrationService() diff --git a/packages/business/src/integration-messenger/connect-page.ts b/packages/business/src/integration-messenger/connect-page.ts new file mode 100644 index 0000000000..464bb41e05 --- /dev/null +++ b/packages/business/src/integration-messenger/connect-page.ts @@ -0,0 +1,104 @@ +import { db } from "@chatbotx.io/database/client" +import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { connectChannelIntegration } from "../inbox/connect-channel" +import { resolveTenantSettings } from "../platform/settings" +import { workspaceService } from "../workspace/service" + +type MessengerPersistentMenu = { + label: string + type: "url" + url: string +} + +/** + * DB-only body of the Messenger page-select connect: optionally creates the + * workspace, resolves the tenant's `appUrl`, then inserts the + * `IntegrationMessenger` row via `connectChannelIntegration`. The Meta API + * calls (`exchangeLongLivedToken`, `subscribePageToAppWebhook`, + * persona/branding) stay in the builder action — `packages/business` has no + * dependency on `@chatbotx.io/integration-messenger`. + * + * `buildPersistentMenus` is a pure callback (no external deps) so the + * branding-menu shape — which needs `getBrandingUrl` from a builder-only + * feature module — can still be built with the `appUrl` resolved inside this + * transaction. + */ +export async function connectMessengerPage(input: { + ownerId: string + userId: string + workspaceId?: string | null + pageName: string + pageId: string + auth: unknown + buildPersistentMenus: (appUrl: string) => MessengerPersistentMenu[] +}): Promise<{ + workspaceId: string + createdWorkspace: boolean + integrationRow: IntegrationMessengerModel + wasCreated: boolean + appUrl: string +}> { + return await db.transaction(async (tx) => { + let workspaceId = input.workspaceId + let createdWorkspace = false + + if (!workspaceId) { + const workspace = await workspaceService.create({ + tx, + createdBy: input.userId, + data: { + name: input.pageName, + timezone: "UTC", + ownerId: input.userId, + }, + }) + workspaceId = workspace.id + createdWorkspace = true + } + + const { appUrl } = await resolveTenantSettings({ workspaceId, tx }) + + const { integration: integrationRow, wasCreated } = + await connectChannelIntegration({ + tx, + ownerId: input.ownerId, + inboxData: { + id: createId(), + workspaceId, + name: input.pageName, + channel: "messenger", + sourceId: input.pageId, + }, + insertIntegration: async (inboxId) => + tx + .insert(integrationMessengerModel) + .values({ + id: createId(), + workspaceId: workspaceId as string, + inboxId, + pageId: input.pageId, + auth: input.auth, + name: input.pageName, + persistentMenus: input.buildPersistentMenus(appUrl), + conversationStarters: [], + personas: [], + }) + .returning() + .then((result) => result[0]), + }) + + if (!integrationRow) { + throw new Error("Failed to create Messenger integration") + } + + return { + workspaceId, + createdWorkspace, + integrationRow, + wasCreated, + appUrl, + } + }) +} diff --git a/packages/business/src/integration-messenger/disconnect.ts b/packages/business/src/integration-messenger/disconnect.ts new file mode 100644 index 0000000000..39185a32bc --- /dev/null +++ b/packages/business/src/integration-messenger/disconnect.ts @@ -0,0 +1,76 @@ +import { and, type DatabaseClient, db, eq } from "@chatbotx.io/database/client" +import { channelTypes } from "@chatbotx.io/database/partials" +import { + integrationMessengerRepository, + metaCapiEventRepository, +} from "@chatbotx.io/database/repositories" +import { tagChannelModel } from "@chatbotx.io/database/schema" +import { coexistService } from "../coexist/service" +import { inboxService } from "../inbox/service" + +/** + * Transactional cleanup for a Messenger disconnect: coexist teardown, the + * polymorphic TagChannel + MetaCapiEvent rows, the integration row itself, + * and the owning inbox. The external Meta-API disconnect call and the audit + * record stay in the builder action (see disconnect-messenger.ts). + * + * Kept in a sibling file (rather than a method on `MessengerIntegrationService`) + * so importing the service alone does not pull in `coexistService` / + * `inboxService` — that chain reaches `@chatbotx.io/analytics` via + * `quotaEnforcementService`, which is a much heavier module graph than the + * plain-query methods on the service need. + */ +export async function deleteMessengerIntegrationWithCleanup(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + tx?: DatabaseClient +}): Promise { + const run = async (tx: DatabaseClient) => { + await coexistService.tearDownForIntegration({ + workspaceId: input.workspaceId, + integrationId: input.id, + channel: "messenger", + currentError: "Integration disconnected", + tx, + }) + + // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId + await tx + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.messenger), + eq(tagChannelModel.integrationId, input.id), + ), + ) + + // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep occupying + // the (workspaceId, channel, sourceKey) dedup slot after a reconnect. + await metaCapiEventRepository.deleteByIntegration( + { + workspaceId: input.workspaceId, + channel: "messenger", + integrationId: input.id, + }, + tx, + ) + + await integrationMessengerRepository.deleteById({ id: input.id }, tx) + + await inboxService.disconnect({ + inboxId: input.inboxId, + ownerId: input.ownerId, + workspaceId: input.workspaceId, + tx, + }) + } + + if (input.tx) { + await run(input.tx) + return + } + + await db.transaction(run) +} diff --git a/packages/business/src/integration-messenger/index.ts b/packages/business/src/integration-messenger/index.ts index 5c5f459590..5501148d62 100644 --- a/packages/business/src/integration-messenger/index.ts +++ b/packages/business/src/integration-messenger/index.ts @@ -1,2 +1,4 @@ +export * from "./connect-page" +export * from "./disconnect" export * from "./schema" export * from "./service" diff --git a/packages/business/src/integration-messenger/service.ts b/packages/business/src/integration-messenger/service.ts index 1cbe24c7f0..1b7230df09 100644 --- a/packages/business/src/integration-messenger/service.ts +++ b/packages/business/src/integration-messenger/service.ts @@ -7,9 +7,24 @@ import { sql, } from "@chatbotx.io/database/client" import type { IntegrationUserInfo } from "@chatbotx.io/database/partials" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" +import { + integrationMessengerModel, + messengerMessageTemplateModel, +} from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +type MetaMessageTemplate = { + id: string + name: string + language: string + category: string + status: string + parameter_format?: string + components: unknown +} + class MessengerIntegrationService extends BaseService { findByInboxId(inboxId: string) { return findOrFail({ table: integrationMessengerModel, where: { inboxId } }) @@ -160,6 +175,104 @@ class MessengerIntegrationService extends BaseService { return rows.length > 0 } + + async updateSettings(input: { + workspaceId: string + id: string + values: Partial + }): Promise { + await db + .update(integrationMessengerModel) + .set(input.values) + .where( + and( + eq(integrationMessengerModel.id, input.id), + eq(integrationMessengerModel.workspaceId, input.workspaceId), + ), + ) + } + + listForWorkspace(input: { workspaceId: string; id?: string }) { + return integrationMessengerRepository.listForWorkspace(input) + } + + /** + * Reconciles the locally-cached Messenger message templates for an + * integration against Meta's list: full sync deletes rows no longer + * present upstream; partial sync (a single created/cloned template) never + * deletes. + */ + async syncMessageTemplates(input: { + integrationMessengerId: string + templates: MetaMessageTemplate[] + isPartialSync: boolean + }): Promise { + await db.transaction(async (tx) => { + if (!input.isPartialSync) { + const existingTemplates = await tx + .select({ + id: messengerMessageTemplateModel.id, + sourceId: messengerMessageTemplateModel.sourceId, + }) + .from(messengerMessageTemplateModel) + .where( + eq( + messengerMessageTemplateModel.integrationMessengerId, + input.integrationMessengerId, + ), + ) + + const incomingSourceIds = new Set( + input.templates.map((template) => template.id), + ) + + const templatesToDelete = existingTemplates.filter( + (template) => !incomingSourceIds.has(template.sourceId), + ) + + if (templatesToDelete.length > 0) { + await tx.delete(messengerMessageTemplateModel).where( + inArray( + messengerMessageTemplateModel.id, + templatesToDelete.map((template) => template.id), + ), + ) + } + } + + for (const template of input.templates) { + await tx + .insert(messengerMessageTemplateModel) + .values([ + { + id: createId(), + name: template.name, + integrationMessengerId: input.integrationMessengerId, + language: template.language, + category: template.category, + status: template.status, + parameterFormat: template.parameter_format ?? "POSITIONAL", + sourceId: template.id, + components: template.components, + }, + ]) + .onConflictDoUpdate({ + target: [ + messengerMessageTemplateModel.integrationMessengerId, + messengerMessageTemplateModel.sourceId, + ], + set: { + name: template.name, + language: template.language, + category: template.category, + status: template.status, + parameterFormat: template.parameter_format ?? "POSITIONAL", + components: template.components, + }, + }) + } + }) + } } export const messengerIntegrationService = new MessengerIntegrationService() diff --git a/packages/business/src/integration-whatsapp/service.ts b/packages/business/src/integration-whatsapp/service.ts index 761375c700..d106b401c9 100644 --- a/packages/business/src/integration-whatsapp/service.ts +++ b/packages/business/src/integration-whatsapp/service.ts @@ -1,15 +1,32 @@ -import type { DatabaseClient } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + isUniqueViolationError, +} from "@chatbotx.io/database/client" import type { WhatsappRegistrationStatus } from "@chatbotx.io/database/partials" -import { integrationWhatsappRepository } from "@chatbotx.io/database/repositories" -import type { IntegrationWhatsappRegistrationError } from "@chatbotx.io/database/schema" +import { + integrationWhatsappRepository, + metaCapiEventRepository, + whatsappCoexistStagingRepository, +} from "@chatbotx.io/database/repositories" +import { + type IntegrationWhatsappRegistrationError, + integrationWhatsappModel, + WHATSAPP_PHONE_NUMBER_UNIQUE_CONSTRAINT, +} from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, WhatsappSignupSessionModel, } from "@chatbotx.io/database/types" import { encryptedDataSchema, encryptUtils } from "@chatbotx.io/encryption" import type { ChannelError } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" import { z } from "zod" import { BaseService } from "../base.service" +import { coexistService } from "../coexist/service" +import { ChatbotXException } from "../errors" +import { connectChannelIntegration } from "../inbox/connect-channel" +import { inboxService } from "../inbox/service" import { logger } from "../logger" import { createDatasetWithFallback } from "../meta-conversions/dataset-fallback" import { platformCredentialService } from "../platform-credential/service" @@ -576,6 +593,256 @@ class IntegrationWhatsappService extends BaseService { ): Promise { return integrationWhatsappRepository.releaseVerificationCodeSlot(input) } + + /** + * Writes the integration and spends the signup session as one unit + * (`connectInTransaction` + `persistIntegration` from the builder action). + * A concurrent connect for the same phone number surfaces as a unique + * violation, mapped here to a typed exception the action can translate to + * an already-translated, user-visible message. + */ + async connectPhoneNumber(input: { + signupSessionClaim?: { + id: string + userId: string + ownerId: string + phoneNumberId: string + } + ownerId: string + userId: string + workspaceId?: string | null + integrationId: string + phoneNumber: { + id: string + verified_name: string + display_phone_number: string + } + displayPhoneNumber: string + phoneName: string + wabaId: string + businessId: string + auth: unknown + isCoexist: boolean + platformType: string + }): Promise<{ + workspaceId: string + createdWorkspace: boolean + integrationRow: IntegrationWhatsappModel + wasCreated: boolean + }> { + try { + return await db.transaction(async (tx) => { + if (input.signupSessionClaim) { + const consumed = await this.consumeSignupSession({ + ...input.signupSessionClaim, + tx, + }) + if (!consumed) { + throw new ChatbotXException( + "Signup session expired", + "whatsappSignupSessionExpired", + 409, + ) + } + } + + let resolvedWorkspaceId = input.workspaceId + let createdWorkspace = false + + if (!resolvedWorkspaceId) { + const workspace = await workspaceService.create({ + tx, + createdBy: input.userId, + data: { + name: input.phoneNumber.verified_name, + timezone: "UTC", + ownerId: input.userId, + }, + }) + resolvedWorkspaceId = workspace.id + createdWorkspace = true + } + + let integrationRow: IntegrationWhatsappModel | undefined + + const { wasCreated } = await connectChannelIntegration({ + tx, + ownerId: input.ownerId, + inboxData: { + id: createId(), + workspaceId: resolvedWorkspaceId, + channel: "whatsapp", + sourceId: input.phoneNumber.id, + name: input.phoneName, + }, + insertIntegration: async (inboxId) => { + const [row] = await tx + .insert(integrationWhatsappModel) + .values({ + id: input.integrationId, + workspaceId: resolvedWorkspaceId as string, + inboxId, + auth: input.auth, + phoneNumberId: input.phoneNumber.id, + wabaId: input.wabaId, + businessId: input.businessId, + name: input.phoneName, + displayPhoneNumber: input.displayPhoneNumber, + isCoexist: input.isCoexist, + platformType: input.platformType, + registrationStatus: "pending_verification", + }) + .onConflictDoUpdate({ + target: [integrationWhatsappModel.inboxId], + set: { + name: input.phoneName, + displayPhoneNumber: input.displayPhoneNumber, + isCoexist: input.isCoexist, + platformType: input.platformType, + updatedAt: new Date(), + }, + }) + .returning() + integrationRow = row + }, + }) + + if (!integrationRow) { + throw new ChatbotXException( + "Failed to persist WhatsApp integration", + "whatsappFailedToPersistIntegration", + ) + } + + return { + workspaceId: resolvedWorkspaceId, + createdWorkspace, + integrationRow, + wasCreated, + } + }) + } catch (err) { + if ( + isUniqueViolationError(err, WHATSAPP_PHONE_NUMBER_UNIQUE_CONSTRAINT) + ) { + throw new ChatbotXException( + "Phone number already connected", + "whatsappPhoneNumberAlreadyConnected", + 409, + ) + } + + throw err + } + } + + /** + * Transactional cleanup for a WhatsApp disconnect: coexist teardown + * (narrows to `channel: "whatsapp"` via `coexistService.tearDownForIntegration`, + * safe because `integrationId` is unique per integration), the coexist + * staging rows for this phone number, the polymorphic MetaCapiEvent rows, + * the integration row itself, and the owning inbox. The external Meta-API + * disconnect call and the (deliberately workspace-less) audit record stay + * in the builder action (see disconnect.action.ts). + */ + async deleteWithCleanup(input: { + workspaceId: string + id: string + phoneNumberId: string + inboxId: string + ownerId: string + tx?: DatabaseClient + }): Promise { + const run = async (tx: DatabaseClient) => { + await coexistService.tearDownForIntegration({ + workspaceId: input.workspaceId, + integrationId: input.id, + channel: "whatsapp", + currentError: "Integration disconnected", + tx, + }) + + await whatsappCoexistStagingRepository.deleteByPhoneNumberId( + { phoneNumberId: input.phoneNumberId }, + tx, + ) + + // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep + // occupying the (workspaceId, channel, sourceKey) dedup slot after a + // reconnect. + await metaCapiEventRepository.deleteByIntegration( + { + workspaceId: input.workspaceId, + channel: "whatsapp", + integrationId: input.id, + }, + tx, + ) + + await integrationWhatsappRepository.deleteById({ id: input.id }, tx) + + await inboxService.disconnect({ + inboxId: input.inboxId, + ownerId: input.ownerId, + workspaceId: input.workspaceId, + tx, + }) + } + + if (input.tx) { + await run(input.tx) + return + } + + await db.transaction(run) + } + + /** + * Stamps `metadata.webhookVerifiedAt` after the webhook challenge + * succeeds. Backs `findIntegrationWhatsappById` + `markWhatsappWebhookVerified` + * (`apps/builder/src/app/integrations/whatsapp/webhook/[integrationId]/route.ts`). + */ + async markWebhookVerified(input: { + id: string + auth: Record & { metadata?: Record } + }): Promise { + const updatedAuth = { + ...input.auth, + metadata: { + ...input.auth.metadata, + webhookVerifiedAt: new Date().toISOString(), + }, + } + + await integrationWhatsappRepository.updateAuthById({ + id: input.id, + auth: updatedAuth, + }) + } + + /** + * Stamps `metadata.subscribeOverrideOk` after a manual-connect webhook + * override subscribe succeeds. Kept separate from `replaceAuth` + * (`service.ts` above), which also writes capi-scope fields this call must + * not touch. + */ + async markWebhookOverrideOk(input: { + id: string + auth: Record & { metadata?: Record } + }): Promise { + const updatedAuth = { + ...input.auth, + metadata: { + ...input.auth.metadata, + subscribeOverrideOk: true, + }, + } + + await integrationWhatsappRepository.updateAuthById({ + id: input.id, + auth: updatedAuth, + }) + } } export const integrationWhatsappService = new IntegrationWhatsappService() diff --git a/packages/business/src/integration/service.ts b/packages/business/src/integration/service.ts index 5e2c0524d6..4f657a268d 100644 --- a/packages/business/src/integration/service.ts +++ b/packages/business/src/integration/service.ts @@ -8,7 +8,9 @@ import { ne, or, } from "@chatbotx.io/database/client" +import type { IntegrationType } from "@chatbotx.io/database/partials" import { + integrationGoogleSheetsModel, integrationInstagramModel, integrationMessengerModel, integrationMetaCatalogModel, @@ -18,6 +20,8 @@ import { integrationZaloModel, } from "@chatbotx.io/database/schema" import type { IntegrationModel } from "@chatbotx.io/database/types" +import type { Oauth2AuthValue } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" export type TokenRefreshErrorChannel = @@ -179,6 +183,38 @@ class IntegrationService extends BaseService { })), ] } + + /** + * Writes the generic `Integration` row created by an OAuth callback, plus + * the type-specific row for Google Sheets (the only callback-created + * integration type with one today). One transaction covers both branches, + * exactly as the callback did before this moved out of the app layer. + */ + async createFromOAuthCallback(input: { + workspaceId: string + integrationType: IntegrationType + googleSheetsAuth?: Oauth2AuthValue | null + }): Promise<{ integrationId: string }> { + const integrationId = createId() + + await db.transaction(async (tx) => { + await tx.insert(integrationModel).values({ + id: integrationId, + workspaceId: input.workspaceId, + integrationType: input.integrationType, + }) + + if (input.integrationType === "googleSheets" && input.googleSheetsAuth) { + await tx.insert(integrationGoogleSheetsModel).values({ + workspaceId: input.workspaceId, + integrationId, + auth: input.googleSheetsAuth, + }) + } + }) + + return { integrationId } + } } export const integrationService = new IntegrationService() diff --git a/packages/database/__tests__/integration-messenger-repository.test.ts b/packages/database/__tests__/integration-messenger-repository.test.ts new file mode 100644 index 0000000000..785aabc7bd --- /dev/null +++ b/packages/database/__tests__/integration-messenger-repository.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// integrationMessengerRepository — clone/template-lookup additions. Mocks +// `db` at the module boundary so query shapes are asserted without touching a +// real database. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + inArray: [column, values], + })), + select: vi.fn(), + findFirstMessengerMessageTemplate: vi.fn(), +})) + +vi.mock("../src/client", () => ({ + and: mocks.and, + db: { + select: mocks.select, + query: { + messengerMessageTemplateModel: { + findFirst: mocks.findFirstMessengerMessageTemplate, + }, + }, + }, + eq: mocks.eq, + inArray: mocks.inArray, + isNull: vi.fn(), + sql: vi.fn(), +})) + +vi.mock("../src/schema", () => ({ + integrationMessengerModel: { + id: "id", + workspaceId: "workspaceId", + pageId: "pageId", + }, + messengerMessageTemplateModel: { + id: "id", + integrationMessengerId: "integrationMessengerId", + }, +})) + +const { integrationMessengerRepository } = await import( + "../src/repositories/integration-messenger/repository" +) + +function selectChain(finalResult: unknown[]) { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => Promise.resolve(finalResult)), + } + return builder +} + +describe("integrationMessengerRepository.listByIds", () => { + test("returns [] for an empty id list without issuing a query", async () => { + const result = await integrationMessengerRepository.listByIds({ ids: [] }) + + expect(result).toEqual([]) + expect(mocks.select).not.toHaveBeenCalled() + }) + + test("queries by inArray when ids are provided", async () => { + const rows = [{ id: "im_1" }] + mocks.select.mockReturnValue(selectChain(rows)) + + const result = await integrationMessengerRepository.listByIds({ + ids: ["im_1"], + }) + + expect(result).toEqual(rows) + expect(mocks.inArray).toHaveBeenCalledWith("id", ["im_1"]) + }) +}) + +describe("integrationMessengerRepository.findMessageTemplateForClone", () => { + test("scopes the lookup by workspace via the integrationMessenger relation", async () => { + const row = { id: "tpl_1" } + mocks.findFirstMessengerMessageTemplate.mockResolvedValue(row) + + const result = + await integrationMessengerRepository.findMessageTemplateForClone({ + workspaceId: "ws_1", + integrationMessengerId: "im_1", + templateId: "tpl_1", + }) + + expect(result).toEqual(row) + expect(mocks.findFirstMessengerMessageTemplate).toHaveBeenCalledWith({ + where: { + id: "tpl_1", + integrationMessengerId: "im_1", + integrationMessenger: { + workspaceId: "ws_1", + }, + }, + }) + }) + + test("returns null when no template matches", async () => { + mocks.findFirstMessengerMessageTemplate.mockResolvedValue(undefined) + + const result = + await integrationMessengerRepository.findMessageTemplateForClone({ + workspaceId: "ws_1", + integrationMessengerId: "im_1", + templateId: "missing", + }) + + expect(result).toBeNull() + }) +}) diff --git a/packages/database/__tests__/integration-whatsapp-client-safe-columns.test.ts b/packages/database/__tests__/integration-whatsapp-client-safe-columns.test.ts new file mode 100644 index 0000000000..d0681af99a --- /dev/null +++ b/packages/database/__tests__/integration-whatsapp-client-safe-columns.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// integrationWhatsappRepository.listClientSafeByWorkspaceId — the column +// allowlist must never widen to include the encrypted `auth` or +// `capiAccessToken` columns; several whatsapps/[id]/* server pages rely on +// this list being safe to forward to client components. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), +})) + +vi.mock("../src/client", () => ({ + and: vi.fn(), + db: { + query: { + integrationWhatsappModel: { + findMany: mocks.findMany, + }, + }, + }, + eq: vi.fn(), + gt: vi.fn(), + inArray: vi.fn(), + isNotNull: vi.fn(), + isNull: vi.fn(), + lt: vi.fn(), + lte: vi.fn(), + or: vi.fn(), + sql: vi.fn(), +})) + +vi.mock("../src/schema", () => ({ + integrationWhatsappModel: { + id: "id", + workspaceId: "workspaceId", + }, + whatsappSignupSessionModel: { + id: "id", + }, +})) + +const { integrationWhatsappRepository } = await import( + "../src/repositories/integration-whatsapp/repository" +) + +describe("integrationWhatsappRepository.listClientSafeByWorkspaceId", () => { + test("the column allowlist never includes auth or capiAccessToken", async () => { + mocks.findMany.mockResolvedValue([]) + + await integrationWhatsappRepository.listClientSafeByWorkspaceId({ + workspaceId: "ws_1", + }) + + expect(mocks.findMany).toHaveBeenCalledOnce() + const call = mocks.findMany.mock.calls[0]?.[0] as { + columns: Record + } + + expect(call.columns).not.toHaveProperty("auth") + expect(call.columns).not.toHaveProperty("capiAccessToken") + expect(call.columns).toEqual( + expect.objectContaining({ + id: true, + name: true, + workspaceId: true, + }), + ) + }) +}) diff --git a/packages/database/__tests__/whatsapp-flow-repository-sync.test.ts b/packages/database/__tests__/whatsapp-flow-repository-sync.test.ts new file mode 100644 index 0000000000..86e544300a --- /dev/null +++ b/packages/database/__tests__/whatsapp-flow-repository-sync.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// whatsappFlowRepository.syncForIntegration — reconciles the locally-cached +// WhatsApp flows for an integration against Meta's list: deletes stale rows, +// updates existing ones, inserts new ones with completedCount "0". +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + inArray: [column, values], + })), + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + del: vi.fn(), + createId: vi.fn(() => "new-id"), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mocks.createId, +})) + +vi.mock("../src/client", () => ({ + and: mocks.and, + db: { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + }, + eq: mocks.eq, + inArray: mocks.inArray, + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + sql: strings, + values, + })), +})) + +vi.mock("../src/schema", () => ({ + whatsappFlowModel: { + id: "id", + sourceId: "sourceId", + integrationWhatsappId: "integrationWhatsappId", + name: "name", + status: "status", + categories: "categories", + validationErrors: "validationErrors", + completedCount: "completedCount", + }, +})) + +const { whatsappFlowRepository } = await import( + "../src/repositories/whatsapp-flow/repository" +) + +function selectChain(finalResult: unknown[]) { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => Promise.resolve(finalResult)), + } + return builder +} + +function mutationChain() { + const builder = { + values: vi.fn(() => Promise.resolve(undefined)), + set: vi.fn(() => builder), + where: vi.fn(() => Promise.resolve(undefined)), + } + return builder +} + +describe("whatsappFlowRepository.syncForIntegration", () => { + test("deletes stale flows, updates existing ones, inserts new ones", async () => { + mocks.select.mockReturnValue( + selectChain([ + { id: "flow_stale", sourceId: "src_stale" }, + { id: "flow_existing", sourceId: "src_existing" }, + ]), + ) + const deleteBuilder = mutationChain() + const updateBuilder = mutationChain() + const insertBuilder = mutationChain() + mocks.del.mockReturnValue(deleteBuilder) + mocks.update.mockReturnValue(updateBuilder) + mocks.insert.mockReturnValue(insertBuilder) + + const tx = { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + } as unknown as Parameters< + typeof whatsappFlowRepository.syncForIntegration + >[1] + + await whatsappFlowRepository.syncForIntegration( + { + integrationWhatsappId: "iw_1", + flows: [ + { + id: "src_existing", + name: "Existing Flow", + status: "PUBLISHED", + categories: ["OTHER"], + validation_errors: [], + }, + { + id: "src_new", + name: "New Flow", + status: "DRAFT", + categories: ["OTHER"], + validation_errors: [], + }, + ], + }, + tx, + ) + + // Stale flow (src_stale not in the incoming set) is deleted. + expect(mocks.del).toHaveBeenCalled() + expect(mocks.inArray).toHaveBeenCalledWith("id", ["flow_stale"]) + + // Existing flow is updated. + expect(updateBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ name: "Existing Flow", status: "PUBLISHED" }), + ) + + // New flow is inserted with completedCount "0" and a fresh id. + expect(insertBuilder.values).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "new-id", + name: "New Flow", + sourceId: "src_new", + integrationWhatsappId: "iw_1", + completedCount: "0", + }), + ]) + }) + + test("opens its own transaction when tx is omitted", async () => { + const transactionSpy = vi.fn( + async (callback: (client: unknown) => Promise) => { + const client = { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + } + return await callback(client) + }, + ) + + mocks.select.mockReturnValue(selectChain([])) + mocks.insert.mockReturnValue(mutationChain()) + + vi.doMock("../src/client", () => ({ + and: mocks.and, + db: { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + transaction: transactionSpy, + }, + eq: mocks.eq, + inArray: mocks.inArray, + sql: vi.fn(), + })) + vi.resetModules() + + const { whatsappFlowRepository: freshRepository } = await import( + "../src/repositories/whatsapp-flow/repository" + ) + + await freshRepository.syncForIntegration({ + integrationWhatsappId: "iw_1", + flows: [], + }) + + expect(transactionSpy).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/database/__tests__/whatsapp-message-template-repository.test.ts b/packages/database/__tests__/whatsapp-message-template-repository.test.ts new file mode 100644 index 0000000000..be6fa92bad --- /dev/null +++ b/packages/database/__tests__/whatsapp-message-template-repository.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// whatsappMessageTemplateRepository.syncForIntegration — reconciles the +// locally-cached WhatsApp message templates for an integration against +// Meta's list: deletes stale rows, updates existing ones, inserts new ones. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + inArray: [column, values], + })), + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + del: vi.fn(), + createId: vi.fn(() => "new-id"), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mocks.createId, +})) + +vi.mock("../src/client", () => ({ + db: { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + }, + eq: mocks.eq, + inArray: mocks.inArray, +})) + +vi.mock("../src/schema", () => ({ + whatsappMessageTemplateModel: { + id: "id", + sourceId: "sourceId", + integrationWhatsappId: "integrationWhatsappId", + name: "name", + language: "language", + category: "category", + status: "status", + components: "components", + }, +})) + +const { whatsappMessageTemplateRepository } = await import( + "../src/repositories/whatsapp-message-template/repository" +) + +function selectChain(finalResult: unknown[]) { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => Promise.resolve(finalResult)), + } + return builder +} + +function mutationChain() { + const builder = { + values: vi.fn(() => Promise.resolve(undefined)), + set: vi.fn(() => builder), + where: vi.fn(() => Promise.resolve(undefined)), + } + return builder +} + +describe("whatsappMessageTemplateRepository.syncForIntegration", () => { + test("deletes stale templates, updates existing ones, inserts new ones", async () => { + mocks.select.mockReturnValue( + selectChain([ + { id: "tpl_stale", sourceId: "src_stale" }, + { id: "tpl_existing", sourceId: "src_existing" }, + ]), + ) + const deleteBuilder = mutationChain() + const updateBuilder = mutationChain() + const insertBuilder = mutationChain() + mocks.del.mockReturnValue(deleteBuilder) + mocks.update.mockReturnValue(updateBuilder) + mocks.insert.mockReturnValue(insertBuilder) + + const tx = { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + } as unknown as Parameters< + typeof whatsappMessageTemplateRepository.syncForIntegration + >[1] + + await whatsappMessageTemplateRepository.syncForIntegration( + { + integrationWhatsappId: "iw_1", + templates: [ + { + id: "src_existing", + name: "Existing Template", + language: "en_US", + category: "UTILITY", + status: "APPROVED", + components: [], + }, + { + id: "src_new", + name: "New Template", + language: "en_US", + category: "MARKETING", + status: "PENDING", + components: [], + }, + ], + }, + tx, + ) + + expect(mocks.del).toHaveBeenCalled() + expect(mocks.inArray).toHaveBeenCalledWith("id", ["tpl_stale"]) + + expect(updateBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Existing Template", + status: "APPROVED", + }), + ) + + expect(insertBuilder.values).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "new-id", + name: "New Template", + sourceId: "src_new", + integrationWhatsappId: "iw_1", + }), + ]) + }) + + test("opens its own transaction when tx is omitted", async () => { + const transactionSpy = vi.fn( + async (callback: (client: unknown) => Promise) => { + const client = { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + } + return await callback(client) + }, + ) + + mocks.select.mockReturnValue(selectChain([])) + mocks.insert.mockReturnValue(mutationChain()) + + vi.doMock("../src/client", () => ({ + db: { + select: mocks.select, + insert: mocks.insert, + update: mocks.update, + delete: mocks.del, + transaction: transactionSpy, + }, + eq: mocks.eq, + inArray: mocks.inArray, + })) + vi.resetModules() + + const { whatsappMessageTemplateRepository: freshRepository } = await import( + "../src/repositories/whatsapp-message-template/repository" + ) + + await freshRepository.syncForIntegration({ + integrationWhatsappId: "iw_1", + templates: [], + }) + + expect(transactionSpy).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index cd2abdc70c..e39b09e24f 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -33,4 +33,5 @@ export * from "./webhook" export * from "./webhook-execution" export * from "./whatsapp-coexist-staging" export * from "./whatsapp-flow" +export * from "./whatsapp-message-template" export * from "./workspace-api-token" diff --git a/packages/database/src/repositories/integration-instagram/repository.ts b/packages/database/src/repositories/integration-instagram/repository.ts index a230b6570c..0144da8fba 100644 --- a/packages/database/src/repositories/integration-instagram/repository.ts +++ b/packages/database/src/repositories/integration-instagram/repository.ts @@ -233,4 +233,23 @@ export const integrationInstagramRepository = { return row ?? null }, + + listForWorkspace( + input: { workspaceId: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationInstagramModel.findMany({ + where: { workspaceId: input.workspaceId }, + orderBy: { createdAt: "asc" }, + }) + }, + + async deleteById( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(integrationInstagramModel) + .where(eq(integrationInstagramModel.id, input.id)) + }, } diff --git a/packages/database/src/repositories/integration-messenger/repository.ts b/packages/database/src/repositories/integration-messenger/repository.ts index cf511dc48a..b0bffd3221 100644 --- a/packages/database/src/repositories/integration-messenger/repository.ts +++ b/packages/database/src/repositories/integration-messenger/repository.ts @@ -1,8 +1,22 @@ import type { EncryptedData } from "@chatbotx.io/encryption" -import { and, type DatabaseClient, db, eq, isNull, sql } from "../../client" -import { integrationMessengerModel } from "../../schema" +import { + and, + type DatabaseClient, + db, + eq, + inArray, + isNull, + sql, +} from "../../client" +import { + integrationMessengerModel, + messengerMessageTemplateModel, +} from "../../schema" import type { IntegrationMessengerModel } from "../../types" +type MessengerMessageTemplateModel = + typeof messengerMessageTemplateModel.$inferSelect + type WorkspaceIntegrationRef = { id: string workspaceId: string @@ -253,4 +267,98 @@ export const integrationMessengerRepository = { return row ?? null }, + + /** + * Resolves a message template to clone, scoped to the source integration AND + * the caller's workspace via the `integrationMessenger` relation — a + * template id from another workspace must never be readable here. + */ + async findMessageTemplateForClone( + input: { + workspaceId: string + integrationMessengerId: string + templateId: string + }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.messengerMessageTemplateModel.findFirst({ + where: { + id: input.templateId, + integrationMessengerId: input.integrationMessengerId, + integrationMessenger: { + workspaceId: input.workspaceId, + }, + }, + }) + + return row ?? null + }, + + async findPageIdById( + input: { workspaceId: string; id: string }, + tx: DatabaseClient = db, + ): Promise<{ pageId: string } | null> { + const row = await tx.query.integrationMessengerModel.findFirst({ + where: { id: input.id, workspaceId: input.workspaceId }, + columns: { pageId: true }, + }) + + return row ?? null + }, + + /** + * Cross-workspace by design — clone targets may live in other workspaces; + * the caller authorises per target via workspace-owner membership. + */ + listByIds( + input: { ids: string[] }, + tx: DatabaseClient = db, + ): Promise { + if (input.ids.length === 0) { + return Promise.resolve([]) + } + return tx + .select() + .from(integrationMessengerModel) + .where(inArray(integrationMessengerModel.id, input.ids)) + }, + + async deleteMessageTemplate( + input: { integrationMessengerId: string; templateId: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(messengerMessageTemplateModel) + .where( + and( + eq(messengerMessageTemplateModel.id, input.templateId), + eq( + messengerMessageTemplateModel.integrationMessengerId, + input.integrationMessengerId, + ), + ), + ) + }, + + listForWorkspace( + input: { workspaceId: string; id?: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationMessengerModel.findMany({ + where: { + workspaceId: input.workspaceId, + ...(input.id ? { id: input.id } : {}), + }, + orderBy: { createdAt: "asc" }, + }) + }, + + async deleteById( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(integrationMessengerModel) + .where(eq(integrationMessengerModel.id, input.id)) + }, } diff --git a/packages/database/src/repositories/integration-whatsapp/repository.ts b/packages/database/src/repositories/integration-whatsapp/repository.ts index 04fa697743..c6ac58ef88 100644 --- a/packages/database/src/repositories/integration-whatsapp/repository.ts +++ b/packages/database/src/repositories/integration-whatsapp/repository.ts @@ -692,6 +692,77 @@ class IntegrationWhatsappRepository { return deleted.length } + + /** + * Client-facing list: explicit allowlist mirroring `integrationWhatsappResource` + * (see `packages/business/src/integration-whatsapp/schema.ts`) so the encrypted + * `auth` and `capiAccessToken` columns can never reach this list, even at + * runtime. Keep this in sync with that pick() when either changes. + */ + listClientSafeByWorkspaceId( + input: { workspaceId: string }, + tx: DatabaseClient = db, + ) { + return tx.query.integrationWhatsappModel.findMany({ + where: { workspaceId: input.workspaceId }, + columns: { + id: true, + name: true, + inboxId: true, + displayPhoneNumber: true, + tokenRefreshError: true, + phoneNumberId: true, + wabaId: true, + hasCapiScope: true, + capiScopeCheckedAt: true, + datasetId: true, + workspaceId: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + with: { + inbox: { + columns: { + id: true, + name: true, + }, + }, + }, + }) + } + + /** + * No workspace scope — the webhook route only has an `integrationId`. + */ + async findById( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.integrationWhatsappModel.findFirst({ + where: { id: input.id }, + }) + + return row ?? null + } + + async updateAuthById( + input: { id: string; auth: unknown }, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(integrationWhatsappModel) + .set({ auth: input.auth }) + .where(eq(integrationWhatsappModel.id, input.id)) + } + + async deleteById( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(integrationWhatsappModel) + .where(eq(integrationWhatsappModel.id, input.id)) + } } export const integrationWhatsappRepository = new IntegrationWhatsappRepository() diff --git a/packages/database/src/repositories/whatsapp-coexist-staging/index.ts b/packages/database/src/repositories/whatsapp-coexist-staging/index.ts index 24c4d98feb..f1b2e7aab5 100644 --- a/packages/database/src/repositories/whatsapp-coexist-staging/index.ts +++ b/packages/database/src/repositories/whatsapp-coexist-staging/index.ts @@ -1,2 +1,5 @@ export type { PurgeProcessedCoexistStagingOptions } from "./repository" -export { purgeProcessedCoexistStaging } from "./repository" +export { + purgeProcessedCoexistStaging, + whatsappCoexistStagingRepository, +} from "./repository" diff --git a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts index 0af1de3eaf..97db7a7006 100644 --- a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts +++ b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts @@ -1,4 +1,5 @@ -import { sql } from "../../client" +import { type DatabaseClient, db, eq, sql } from "../../client" +import { whatsappCoexistStagingModel } from "../../schema" import { type ChunkedPurgeStopReason, chunkedPurge } from "../chunked-purge" export type PurgeProcessedCoexistStagingOptions = { @@ -26,3 +27,14 @@ export function purgeProcessedCoexistStaging( ...bounds, }) } + +export const whatsappCoexistStagingRepository = { + async deleteByPhoneNumberId( + input: { phoneNumberId: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(whatsappCoexistStagingModel) + .where(eq(whatsappCoexistStagingModel.phoneNumberId, input.phoneNumberId)) + }, +} diff --git a/packages/database/src/repositories/whatsapp-flow/repository.ts b/packages/database/src/repositories/whatsapp-flow/repository.ts index e2615100c0..3f4091bb43 100644 --- a/packages/database/src/repositories/whatsapp-flow/repository.ts +++ b/packages/database/src/repositories/whatsapp-flow/repository.ts @@ -1,4 +1,5 @@ -import { and, type DatabaseClient, db, eq, sql } from "../../client" +import { createId } from "@chatbotx.io/utils" +import { and, type DatabaseClient, db, eq, inArray, sql } from "../../client" import { whatsappFlowModel } from "../../schema" type WhatsappFlowSourceRef = { @@ -6,6 +7,19 @@ type WhatsappFlowSourceRef = { sourceId: string } +type WhatsappFlowSyncInput = { + id: string + name: string + status: string + categories: unknown + validation_errors: unknown +} + +type SyncForIntegrationInput = { + integrationWhatsappId: string + flows: WhatsappFlowSyncInput[] +} + class WhatsappFlowRepository { async incrementCompletedCount( input: WhatsappFlowSourceRef, @@ -26,6 +40,85 @@ class WhatsappFlowRepository { ), ) } + + /** + * Reconciles the locally-cached WhatsApp flows for an integration against + * the incoming list from Meta: deletes stale flows, updates existing ones, + * inserts new ones. Pure data reconciliation — no business rules — so when + * `tx` is omitted it opens its own transaction. + */ + async syncForIntegration( + input: SyncForIntegrationInput, + tx?: DatabaseClient, + ): Promise { + const run = async (client: DatabaseClient) => { + const existingFlows = await client + .select({ + id: whatsappFlowModel.id, + sourceId: whatsappFlowModel.sourceId, + }) + .from(whatsappFlowModel) + .where( + eq( + whatsappFlowModel.integrationWhatsappId, + input.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set(input.flows.map((flow) => flow.id)) + + const flowsToDelete = existingFlows.filter( + (flow) => !incomingSourceIds.has(flow.sourceId), + ) + + if (flowsToDelete.length > 0) { + await client.delete(whatsappFlowModel).where( + inArray( + whatsappFlowModel.id, + flowsToDelete.map((flow) => flow.id), + ), + ) + } + + for (const flow of input.flows) { + const existing = existingFlows.find((f) => f.sourceId === flow.id) + + if (existing) { + await client + .update(whatsappFlowModel) + .set({ + name: flow.name, + status: flow.status, + categories: flow.categories, + validationErrors: flow.validation_errors, + }) + .where(eq(whatsappFlowModel.id, existing.id)) + } else { + await client.insert(whatsappFlowModel).values([ + { + id: createId(), + name: flow.name, + integrationWhatsappId: input.integrationWhatsappId, + sourceId: flow.id, + status: flow.status, + categories: flow.categories, + validationErrors: flow.validation_errors, + completedCount: "0", + }, + ]) + } + } + } + + if (tx) { + await run(tx) + return + } + + await db.transaction(async (transactionClient) => { + await run(transactionClient) + }) + } } export const whatsappFlowRepository = new WhatsappFlowRepository() diff --git a/packages/database/src/repositories/whatsapp-message-template/index.ts b/packages/database/src/repositories/whatsapp-message-template/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/whatsapp-message-template/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/whatsapp-message-template/repository.ts b/packages/database/src/repositories/whatsapp-message-template/repository.ts new file mode 100644 index 0000000000..3b83c721b2 --- /dev/null +++ b/packages/database/src/repositories/whatsapp-message-template/repository.ts @@ -0,0 +1,106 @@ +import { createId } from "@chatbotx.io/utils" +import { type DatabaseClient, db, eq, inArray } from "../../client" +import { whatsappMessageTemplateModel } from "../../schema" + +type WhatsappMessageTemplateSyncInput = { + id: string + name: string + language: string + category: string + status: string + components: unknown +} + +type SyncForIntegrationInput = { + integrationWhatsappId: string + templates: WhatsappMessageTemplateSyncInput[] +} + +class WhatsappMessageTemplateRepository { + /** + * Reconciles the locally-cached WhatsApp message templates for an + * integration against the incoming list from Meta: deletes stale templates, + * updates existing ones, inserts new ones. Pure data reconciliation — no + * business rules — so when `tx` is omitted it opens its own transaction. + */ + async syncForIntegration( + input: SyncForIntegrationInput, + tx?: DatabaseClient, + ): Promise { + const run = async (client: DatabaseClient) => { + const existingTemplates = await client + .select({ + id: whatsappMessageTemplateModel.id, + sourceId: whatsappMessageTemplateModel.sourceId, + }) + .from(whatsappMessageTemplateModel) + .where( + eq( + whatsappMessageTemplateModel.integrationWhatsappId, + input.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set( + input.templates.map((template) => template.id), + ) + + const templatesToDelete = existingTemplates.filter( + (template) => !incomingSourceIds.has(template.sourceId), + ) + + if (templatesToDelete.length > 0) { + await client.delete(whatsappMessageTemplateModel).where( + inArray( + whatsappMessageTemplateModel.id, + templatesToDelete.map((template) => template.id), + ), + ) + } + + for (const template of input.templates) { + const existing = existingTemplates.find( + (t) => t.sourceId === template.id, + ) + + if (existing) { + await client + .update(whatsappMessageTemplateModel) + .set({ + name: template.name, + language: template.language, + category: template.category, + status: template.status, + components: template.components, + }) + .where(eq(whatsappMessageTemplateModel.id, existing.id)) + } else { + await client.insert(whatsappMessageTemplateModel).values([ + { + id: createId(), + name: template.name, + integrationWhatsappId: input.integrationWhatsappId, + language: template.language, + category: template.category, + status: template.status, + sourceId: template.id, + components: template.components, + }, + ]) + } + } + } + + if (tx) { + await run(tx) + return + } + + await db.transaction(async (transactionClient) => { + await run(transactionClient) + }) + } +} + +export const whatsappMessageTemplateRepository = + new WhatsappMessageTemplateRepository()