diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index 3b0f81df97..c242e8b418 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -452,11 +452,36 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "inboxTeams.list", "path": "/v1/teams", }, + { + "method": "PUT", + "operationId": "integrations.connectAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, + { + "method": "DELETE", + "operationId": "integrations.disconnectAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, + { + "method": "GET", + "operationId": "integrations.get", + "path": "/v1/integrations/{id}", + }, + { + "method": "GET", + "operationId": "integrations.getAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, { "method": "GET", "operationId": "integrations.list", "path": "/v1/integrations", }, + { + "method": "GET", + "operationId": "integrations.tokenErrors", + "path": "/v1/integrations/status/token-errors", + }, { "method": "GET", "operationId": "keywords.list", diff --git a/apps/builder/__tests__/create-api.action.test.ts b/apps/builder/__tests__/create-api.action.test.ts index 9e95f88ad6..e9c16bebb5 100644 --- a/apps/builder/__tests__/create-api.action.test.ts +++ b/apps/builder/__tests__/create-api.action.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ connect: vi.fn(), findWorkspaceOrFail: vi.fn(), createWorkspace: vi.fn(), + hasWorkspaceAccess: vi.fn(async () => true), generateApiChannelToken: vi.fn(async () => ({ token: "plain-token", tokenHash: "token-hash", @@ -24,6 +25,7 @@ vi.mock("@/lib/safe-action", () => { vi.mock("@chatbotx.io/business", () => ({ assertPublicUrl: mocks.assertPublicUrl, + hasWorkspaceAccess: mocks.hasWorkspaceAccess, integrationApiService: { connect: mocks.connect }, workspaceService: { findOrFail: mocks.findWorkspaceOrFail, @@ -52,6 +54,7 @@ type ActionHandler = (args: { describe("createApiAction", () => { beforeEach(() => { vi.clearAllMocks() + mocks.hasWorkspaceAccess.mockResolvedValue(true) mocks.findWorkspaceOrFail.mockResolvedValue({ id: "workspace-1", ownerId: "owner-1", @@ -92,4 +95,20 @@ describe("createApiAction", () => { token: "plain-token", }) }) + + test("rejects a workspaceId the caller is not a member of", async () => { + mocks.hasWorkspaceAccess.mockResolvedValue(false) + + await expect( + (createApiAction as unknown as ActionHandler)({ + parsedInput: { + workspaceId: "workspace-1", + name: "Support API", + }, + ctx: { user: { id: "intruder-1" } }, + }), + ).rejects.toThrow() + + expect(mocks.connect).not.toHaveBeenCalled() + }) }) diff --git a/apps/builder/__tests__/disconnect-meta-actions.test.ts b/apps/builder/__tests__/disconnect-meta-actions.test.ts index 715ea882b6..f9d35a2903 100644 --- a/apps/builder/__tests__/disconnect-meta-actions.test.ts +++ b/apps/builder/__tests__/disconnect-meta-actions.test.ts @@ -29,6 +29,18 @@ const mocks = vi.hoisted(() => { messengerDisconnect: vi.fn().mockResolvedValue(undefined), messengerDisconnectSafe: vi.fn(() => false), messengerExists: vi.fn().mockResolvedValue(false), + messengerServiceDisconnect: vi.fn( + (props: { + id: string + tx: { delete: (...args: unknown[]) => unknown } + }) => Promise.resolve(props.tx.delete()), + ), + instagramServiceDisconnect: vi.fn( + (props: { + id: string + tx: { delete: (...args: unknown[]) => unknown } + }) => Promise.resolve(props.tx.delete()), + ), instagramDisconnect: vi.fn().mockResolvedValue(undefined), instagramFacebookDisconnect: vi.fn().mockResolvedValue(undefined), subscribePageToAppWebhook: vi.fn().mockResolvedValue(undefined), @@ -47,8 +59,16 @@ vi.mock("@chatbotx.io/business", () => ({ tearDownForIntegration: mocks.coexistTearDownForIntegration, }, inboxService: { disconnect: mocks.inboxDisconnect }, - instagramIntegrationService: { existsForPage: mocks.instagramExists }, - messengerIntegrationService: { existsForPage: mocks.messengerExists }, + instagramIntegrationService: { + existsForPage: mocks.instagramExists, + findByIdForWorkspace: mocks.findOrFail, + disconnect: mocks.instagramServiceDisconnect, + }, + messengerIntegrationService: { + existsForPage: mocks.messengerExists, + findByIdForWorkspace: mocks.findOrFail, + disconnect: mocks.messengerServiceDisconnect, + }, workspaceService: { findById: mocks.workspaceFindById }, })) diff --git a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts index 50308abbb5..f6ebcfd775 100644 --- a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts +++ b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest" +const LIVE_RUN_STATUSES = ["init", "running", "waiting"] + const mocks = vi.hoisted(() => { const txChain = { set: vi.fn(), @@ -30,34 +32,66 @@ const mocks = vi.hoisted(() => { } }) +// Mirrors `integrationWhatsappService.disconnect`'s real transaction body — +// the test asserts on these same tx calls, so the mock replicates them +// rather than mocking `@chatbotx.io/business` transitively (which would +// require booting the real business/database module graph). +const integrationWhatsappServiceDisconnect = vi.fn( + async (props: { + integrationWhatsapp: { id: string; inboxId: string; phoneNumberId: string } + ownerId: string + workspaceId: string + tx: typeof mocks.tx + }) => { + const tx = props.tx as unknown as { + update: (arg?: unknown) => { + set: (arg?: unknown) => { where: (arg?: unknown) => unknown } + } + delete: (arg?: unknown) => unknown + } + + tx.update() + .set() + .where({ + conditions: [ + { field: "integrationId", value: props.integrationWhatsapp.id }, + { field: "status", values: LIVE_RUN_STATUSES }, + ], + }) + tx.delete() + await mocks.metaCapiDeleteByIntegration( + { + workspaceId: props.workspaceId, + channel: "whatsapp", + integrationId: props.integrationWhatsapp.id, + }, + props.tx, + ) + tx.delete({ id: "whatsappId" }) + await mocks.inboxDisconnect({ + inboxId: props.integrationWhatsapp.inboxId, + ownerId: props.ownerId, + workspaceId: props.workspaceId, + reason: "manual", + tx: props.tx, + }) + }, +) + vi.mock("@chatbotx.io/business", () => ({ - inboxService: { disconnect: mocks.inboxDisconnect }, + integrationWhatsappService: { + disconnect: integrationWhatsappServiceDisconnect, + }, workspaceService: { findById: mocks.workspaceFindById }, })) 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", () => ({ - LIVE_RUN_STATUSES: ["init", "running", "waiting"], - metaCapiEventRepository: { - deleteByIntegration: mocks.metaCapiDeleteByIntegration, - }, })) vi.mock("@chatbotx.io/database/schema", () => ({ - coexistSyncRunModel: { - finishedAt: "finishedAt", - integrationId: "integrationId", - status: "status", - }, integrationWhatsappModel: { id: "whatsappId" }, - whatsappCoexistStagingModel: { phoneNumberId: "phoneNumberId" }, })) vi.mock("@chatbotx.io/integration-whatsapp", () => ({ diff --git a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts index 0667d83cda..7bf59a785e 100644 --- a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts +++ b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts @@ -3,21 +3,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ - connectChannelIntegration: vi.fn(), + connect: vi.fn(), findWorkspaceById: vi.fn(), transaction: vi.fn(), - insert: vi.fn(), - values: vi.fn(), - onConflictDoUpdate: vi.fn(), - returning: vi.fn(), auditRecord: vi.fn(), handleRequest: vi.fn(), - createId: vi.fn(() => "generated-integration-id"), redirect: vi.fn(), })) vi.mock("@chatbotx.io/business", () => ({ - connectChannelIntegration: mocks.connectChannelIntegration, + tiktokIntegrationService: { connect: mocks.connect }, workspaceService: { findById: mocks.findWorkspaceById }, })) @@ -40,14 +35,6 @@ vi.mock("@chatbotx.io/database/client", () => ({ db: { transaction: mocks.transaction }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationTiktokModel: { id: "id", openId: "openId" }, -})) - -vi.mock("@chatbotx.io/utils", () => ({ - createId: mocks.createId, -})) - vi.mock("next/navigation", () => ({ redirect: mocks.redirect, })) @@ -74,25 +61,15 @@ describe("connectTiktokHandler", () => { }, }) mocks.transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => - fn({ - insert: mocks.insert, - }), + fn({}), ) - mocks.insert.mockReturnValue({ values: mocks.values }) - mocks.values.mockReturnValue({ - onConflictDoUpdate: mocks.onConflictDoUpdate, - }) - mocks.onConflictDoUpdate.mockReturnValue({ returning: mocks.returning }) - mocks.returning.mockResolvedValue([{ id: "existing-integration-id" }]) }) test("records reconnect audit with the persisted TikTok integration id on conflict", async () => { - mocks.connectChannelIntegration.mockImplementation( - async ({ insertIntegration }) => { - const integration = await insertIntegration("inbox-1", false) - return { wasCreated: false, integration } - }, - ) + mocks.connect.mockResolvedValue({ + wasCreated: false, + integration: { id: "existing-integration-id" }, + }) await connectTiktokHandler({ tiktokSettings: { clientId: "client", clientSecret: "secret" }, @@ -102,15 +79,14 @@ describe("connectTiktokHandler", () => { redirectUrl: "https://app.example.com/integrations/tiktok/callback", }) - expect(mocks.values).toHaveBeenCalledWith( + expect(mocks.connect).toHaveBeenCalledWith( expect.objectContaining({ - id: "generated-integration-id", - inboxId: "inbox-1", workspaceId: "workspace-1", openId: "open-id-1", + username: "shop_1", + displayName: "TikTok Shop", }), ) - expect(mocks.returning).toHaveBeenCalledWith({ id: "id" }) expect(mocks.auditRecord).toHaveBeenCalledTimes(1) expect(mocks.auditRecord).toHaveBeenCalledWith({ userId: "admin-1", diff --git a/apps/builder/__tests__/integrations-public-api.test.ts b/apps/builder/__tests__/integrations-public-api.test.ts new file mode 100644 index 0000000000..07df3cd738 --- /dev/null +++ b/apps/builder/__tests__/integrations-public-api.test.ts @@ -0,0 +1,409 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +class MockChatbotXException extends Error { + code: string + constructor(message: string, code = "systemError") { + super(message) + this.code = code + } +} + +vi.mock("@chatbotx.io/business/errors", () => ({ + notFoundException: vi.fn( + (message: string) => new MockChatbotXException(message, "notFound"), + ), + validationException: vi.fn( + (_field: string, message: string) => + new MockChatbotXException(message, "validation"), + ), +})) + +const aiIntegrationService = { + invalidateCache: vi.fn(), +} + +vi.mock("@chatbotx.io/ai/server", () => ({ aiIntegrationService })) +vi.mock("@chatbotx.io/ai", () => ({ + aiProviders: { + enum: { + claude: "claude", + deepseek: "deepseek", + gemini: "gemini", + openai: "openai", + }, + }, +})) + +const verifyAiProviderApiKey = vi.fn(async () => true) +vi.mock("@/features/integration-ai/lib/verify-api-key", () => ({ + verifyAiProviderApiKey, +})) + +const integrationService = { + listByWorkspaceId: vi.fn(), + findByIdForWorkspace: vi.fn(), + findTokenRefreshErrorsByWorkspaceId: vi.fn(), +} + +const integrationClaudeService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationDeepSeekService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationGeminiService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationOpenAIService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + integrationService, + integrationClaudeService, + integrationDeepSeekService, + integrationGeminiService, + integrationOpenAIService, +})) + +await import("@/features/integrations/api/public/crud") +await import("@/features/integrations/api/public/ai") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +beforeEach(() => { + vi.clearAllMocks() + verifyAiProviderApiKey.mockResolvedValue(true) +}) + +describe("GET /v1/integrations", () => { + const procedure = findProcedure("GET", "/v1/integrations") + + test("delegates to integrationService.listByWorkspaceId", async () => { + integrationService.listByWorkspaceId.mockResolvedValueOnce([ + { id: "integration-1" }, + ]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(result).toEqual({ data: [{ id: "integration-1" }], pageCount: 1 }) + expect(integrationService.listByWorkspaceId).toHaveBeenCalledWith( + "workspace-1", + ) + }) +}) + +describe("GET /v1/integrations/{id}", () => { + const procedure = findProcedure("GET", "/v1/integrations/{id}") + + test("delegates to integrationService.findByIdForWorkspace", async () => { + integrationService.findByIdForWorkspace.mockResolvedValueOnce({ + id: "integration-1", + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "integration-1" }, + }) + + expect(result).toEqual({ id: "integration-1" }) + expect(integrationService.findByIdForWorkspace).toHaveBeenCalledWith({ + id: "integration-1", + workspaceId: "workspace-1", + }) + }) + + test("throws notFound when the integration does not exist", async () => { + integrationService.findByIdForWorkspace.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing" }, + }), + ).rejects.toThrow("Integration not found") + }) +}) + +describe("GET /v1/integrations/status/token-errors", () => { + const procedure = findProcedure("GET", "/v1/integrations/status/token-errors") + + test("delegates to integrationService.findTokenRefreshErrorsByWorkspaceId", async () => { + integrationService.findTokenRefreshErrorsByWorkspaceId.mockResolvedValueOnce( + [{ id: "zalo-1", channel: "zalo", name: "Shop", error: "expired" }], + ) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + }) + + expect(result).toEqual({ + data: [{ id: "zalo-1", channel: "zalo", name: "Shop", error: "expired" }], + }) + }) +}) + +describe("GET /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("GET", "/v1/integrations/ai/{provider}") + + test("never returns the secret auth field", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce({ + id: "claude-1", + model: "claude-opus", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: true, + auth: { authType: "secretText", secretText: "sk-real-secret-value" }, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }) + + expect(result).toEqual({ + id: "claude-1", + model: "claude-opus", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: true, + hasApiKey: true, + }) + expect(JSON.stringify(result)).not.toContain("sk-real-secret-value") + expect(result).not.toHaveProperty("auth") + }) + + test("hasApiKey is false when no auth is stored", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce({ + id: "claude-1", + model: "claude-opus", + temperature: null, + maxOutputTokens: 1024, + autoReply: false, + auth: null, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }) + + expect(result).toMatchObject({ hasApiKey: false }) + }) + + test("throws notFound when the provider is not connected", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }), + ).rejects.toThrow("claude integration not found") + }) + + test("dispatches to the correct provider service", async () => { + integrationOpenAIService.findByWorkspaceId.mockResolvedValueOnce({ + id: "openai-1", + model: "gpt-5", + temperature: 1, + maxOutputTokens: 2048, + autoReply: true, + auth: { authType: "secretText", secretText: "sk-openai" }, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "openai" }, + }) + + expect(integrationOpenAIService.findByWorkspaceId).toHaveBeenCalledWith( + "workspace-1", + ) + expect(integrationClaudeService.findByWorkspaceId).not.toHaveBeenCalled() + }) +}) + +describe("PUT /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("PUT", "/v1/integrations/ai/{provider}") + + test("connects then returns the resource without the secret", async () => { + integrationGeminiService.connect.mockResolvedValueOnce(undefined) + integrationGeminiService.findByWorkspaceId.mockResolvedValueOnce({ + id: "gemini-1", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: false, + auth: { authType: "secretText", secretText: "sk-gemini-secret" }, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }) + + expect(integrationGeminiService.connect).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }) + expect(JSON.stringify(result)).not.toContain("sk-gemini-secret") + }) + + test("invalidates the AI integration cache after connecting", async () => { + integrationGeminiService.connect.mockResolvedValueOnce(undefined) + integrationGeminiService.findByWorkspaceId.mockResolvedValueOnce({ + id: "gemini-1", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: false, + auth: { authType: "secretText", secretText: "sk-gemini-secret" }, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }) + + expect(aiIntegrationService.invalidateCache).toHaveBeenCalledWith( + "workspace-1", + "gemini", + ) + }) + + test("rejects an invalid API key without persisting it", async () => { + verifyAiProviderApiKey.mockResolvedValueOnce(false) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "bad-key", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }), + ).rejects.toThrow() + + expect(integrationGeminiService.connect).not.toHaveBeenCalled() + expect(aiIntegrationService.invalidateCache).not.toHaveBeenCalled() + }) +}) + +describe("DELETE /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("DELETE", "/v1/integrations/ai/{provider}") + + test("delegates to the provider's disconnect", async () => { + integrationDeepSeekService.disconnect.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "deepseek" }, + }) + + expect(integrationDeepSeekService.disconnect).toHaveBeenCalledWith( + "workspace-1", + ) + }) + + test("invalidates the AI integration cache after disconnecting", async () => { + integrationDeepSeekService.disconnect.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "deepseek" }, + }) + + expect(aiIntegrationService.invalidateCache).toHaveBeenCalledWith( + "workspace-1", + "deepseek", + ) + }) + + test("responds with 204 (no body)", () => { + expect(procedure.route.successStatus).toBe(204) + }) +}) diff --git a/apps/builder/__tests__/integrations-public-scope.test.ts b/apps/builder/__tests__/integrations-public-scope.test.ts new file mode 100644 index 0000000000..dc593f9e63 --- /dev/null +++ b/apps/builder/__tests__/integrations-public-scope.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest" + +// Same rationale as contacts-public-scope.test.ts: importing the real +// integrations public router transitively pulls in +// `@chatbotx.io/database/client` (opens a real `pg.Pool`) and `@/orpc`'s +// `authorizedAPI` chain (boots the full better-auth stack via +// `@/middlewares/auth`). Neither is reachable from this test — it only +// inspects which scope each submodule registered its procedures under — so +// both are stubbed to keep the import side-effect-free. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), + workspaceAuthorizedMidddleware: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => { + const proxy: unknown = new Proxy(() => proxy, { get: () => proxy }) + return { db: proxy } +}) + +const workspaceTokenAuthAPIForScope = vi.hoisted(() => + vi.fn((_scope: string) => { + const chain = { + route: vi.fn(() => chain), + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn(() => ({})), + } + return chain + }), +) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +// Each submodule calls `workspaceTokenAuthAPIForScope` exactly once at import +// time — `packages/utils`' Snowflake ID generator is a process-wide singleton +// that throws on re-init, so every submodule is imported exactly once here +// (no `vi.resetModules()` between them) and the full accumulated call list is +// asserted at the end, per submodule slice. +await import("@/features/integrations/api/public/crud") +const crudCallCount = workspaceTokenAuthAPIForScope.mock.calls.length + +await import("@/features/integrations/api/public/ai") +const aiCallCount = workspaceTokenAuthAPIForScope.mock.calls.length + +const allScopeCalls = workspaceTokenAuthAPIForScope.mock.calls.map( + (call) => call[0], +) + +describe("integrations public router scope wiring", () => { + test("crud.ts registers under the 'integrations' scope", () => { + expect(allScopeCalls.slice(0, crudCallCount)).toEqual(["integrations"]) + }) + + test("ai.ts registers under the 'integrations' scope", () => { + expect(allScopeCalls.slice(crudCallCount, aiCallCount)).toEqual([ + "integrations", + ]) + }) +}) diff --git a/apps/builder/__tests__/messenger-clone-template-components.test.ts b/apps/builder/__tests__/messenger-clone-template-components.test.ts index a0d04e41de..43bf33c35d 100644 --- a/apps/builder/__tests__/messenger-clone-template-components.test.ts +++ b/apps/builder/__tests__/messenger-clone-template-components.test.ts @@ -17,6 +17,16 @@ vi.mock("@chatbotx.io/database/schema", () => ({ messengerMessageTemplateModel: {}, })) +vi.mock("@chatbotx.io/business", () => ({ + messengerIntegrationService: { + findByIdForWorkspace: vi.fn(), + findByIds: vi.fn(), + }, + messengerMessageTemplateService: { + findByIdForIntegration: vi.fn(), + }, +})) + vi.mock("@chatbotx.io/integration-messenger/apis/message-templates", () => ({ createPageMessageTemplate: vi.fn(), })) diff --git a/apps/builder/__tests__/update-messenger-action.test.ts b/apps/builder/__tests__/update-messenger-action.test.ts index 4027a53acd..f8c6778eaa 100644 --- a/apps/builder/__tests__/update-messenger-action.test.ts +++ b/apps/builder/__tests__/update-messenger-action.test.ts @@ -7,6 +7,14 @@ const mocks = vi.hoisted(() => { const txSet = vi.fn(() => ({ where: txWhere })) const txUpdate = vi.fn(() => ({ set: txSet })) + const updateProfileFields = vi.fn( + ( + _props: { id: string }, + _data: Record, + tx: { update: typeof txUpdate }, + ) => Promise.resolve(tx.update().set().where()), + ) + return { buildContext: vi.fn(), dbTransaction: vi.fn( @@ -22,11 +30,15 @@ const mocks = vi.hoisted(() => { txSet, txUpdate, txWhere, + updateProfileFields, } }) vi.mock("@chatbotx.io/business", () => ({ buildContext: mocks.buildContext, + messengerIntegrationService: { + updateProfileFields: mocks.updateProfileFields, + }, })) vi.mock("@chatbotx.io/business/branding", () => ({ diff --git a/apps/builder/__tests__/update-webchat-action-permission.test.ts b/apps/builder/__tests__/update-webchat-action-permission.test.ts index 5905a85420..1577619d8d 100644 --- a/apps/builder/__tests__/update-webchat-action-permission.test.ts +++ b/apps/builder/__tests__/update-webchat-action-permission.test.ts @@ -3,8 +3,8 @@ import { beforeEach, expect, test, vi } from "vitest" const mockHasWorkspacePermission = vi.fn() -const mockFindOrFail = vi.fn() -const mockDbTransaction = vi.fn() +const mockFindByWorkspaceIdAndId = vi.fn() +const mockUpdate = vi.fn() const mockIsCommunity = vi.fn(() => false) const SUPER_ADMIN_ERROR_RE = /super admin/i @@ -30,14 +30,15 @@ vi.mock("@/lib/auth/permission-routes", () => ({ hasWorkspacePermission: mockHasWorkspacePermission, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mockDbTransaction }, - eq: vi.fn(), - findOrFail: mockFindOrFail, +vi.mock("@chatbotx.io/business", () => ({ + integrationWebchatService: { + findByWorkspaceIdAndId: mockFindByWorkspaceIdAndId, + update: mockUpdate, + }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationWebchatModel: { id: "id-column" }, +vi.mock("@chatbotx.io/business/branding", () => ({ + ensureBrandingMenuEntry: vi.fn((menus: unknown) => menus), })) const { updateWebchatAction } = await import( @@ -61,12 +62,7 @@ const makeInput = (permissions: Record) => ({ beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue({ id: "webchat-1" }) - mockDbTransaction.mockImplementation(async (fn: (tx: unknown) => unknown) => - fn({ - update: () => ({ set: () => ({ where: vi.fn() }) }), - }), - ) + mockUpdate.mockResolvedValue(undefined) }) test("rejects a workspace member without superAdmin permission", async () => { @@ -81,8 +77,7 @@ test("rejects a workspace member without superAdmin permission", async () => { // The permission check must short-circuit before any write is attempted — // this is the guard that closes the bypass of the edit page's // requireWorkspacePermission(workspaceId, "superAdmin") gate. - expect(mockFindOrFail).not.toHaveBeenCalled() - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() }) test("gates on the permissions supplied by the middleware ctx", async () => { @@ -98,7 +93,7 @@ test("gates on the permissions supplied by the middleware ctx", async () => { { superAdmin: false }, "superAdmin", ) - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() }) test("proceeds to update when the caller is a superAdmin", async () => { @@ -108,10 +103,8 @@ test("proceeds to update when the caller is a superAdmin", async () => { makeInput({ superAdmin: true }), ) - expect(mockFindOrFail).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "webchat-1", workspaceId: "workspace-1" }, - }), + expect(mockUpdate).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "webchat-1" }, + expect.objectContaining({ name: "Support" }), ) - expect(mockDbTransaction).toHaveBeenCalled() }) diff --git a/apps/builder/src/features/integration-ai/lib/verify-api-key.ts b/apps/builder/src/features/integration-ai/lib/verify-api-key.ts index e9c692853c..cdb297e880 100644 --- a/apps/builder/src/features/integration-ai/lib/verify-api-key.ts +++ b/apps/builder/src/features/integration-ai/lib/verify-api-key.ts @@ -5,33 +5,37 @@ const VERIFY_TIMEOUT_MS = 10_000 const UNAUTHORIZED_STATUSES = new Set([401, 403]) type VerifyConfig = { - url: string - headers: (apiKey: string) => Record + url: (apiKey: string) => string + headers?: (apiKey: string) => Record } // Lightweight "list models" probes used purely to validate an API key. const verifyConfigByProvider: Partial> = { [aiProviders.enum.claude]: { - url: "https://api.anthropic.com/v1/models", + url: () => "https://api.anthropic.com/v1/models", headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01", }), }, [aiProviders.enum.deepseek]: { - url: "https://api.deepseek.com/models", + url: () => "https://api.deepseek.com/models", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), }, + [aiProviders.enum.gemini]: { + url: (apiKey) => + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, + }, [aiProviders.enum.openai]: { - url: "https://api.openai.com/v1/models", + url: () => "https://api.openai.com/v1/models", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), }, [aiProviders.enum.openrouter]: { - url: "https://openrouter.ai/api/v1/key", + url: () => "https://openrouter.ai/api/v1/key", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), @@ -56,8 +60,8 @@ export async function verifyAiProviderApiKey( } try { - await ky.get(config.url, { - headers: config.headers(apiKey), + await ky.get(config.url(apiKey), { + headers: config.headers?.(apiKey), timeout: VERIFY_TIMEOUT_MS, retry: 0, }) diff --git a/apps/builder/src/features/integration-api/actions/create-api.action.ts b/apps/builder/src/features/integration-api/actions/create-api.action.ts index 473df30765..9fc71380d6 100644 --- a/apps/builder/src/features/integration-api/actions/create-api.action.ts +++ b/apps/builder/src/features/integration-api/actions/create-api.action.ts @@ -2,9 +2,11 @@ import { assertPublicUrl, + hasWorkspaceAccess, integrationApiService, workspaceService, } from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" import { generateApiChannelToken, generateSigningSecret, @@ -24,6 +26,9 @@ export const createApiAction = authActionClient let ownerId = ctx.user.id if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) diff --git a/apps/builder/src/features/integration-claude/actions/connect.action.ts b/apps/builder/src/features/integration-claude/actions/connect.action.ts index 259283b0ef..0027da5099 100644 --- a/apps/builder/src/features/integration-claude/actions/connect.action.ts +++ b/apps/builder/src/features/integration-claude/actions/connect.action.ts @@ -1,15 +1,7 @@ "use server" - import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationClaudeModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationClaudeService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -44,50 +36,12 @@ export const connectClaudeAction = workspaceActionClient }) } - const integrationClaude = await db.query.integrationClaudeModel.findFirst( - { - where: { workspaceId }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationClaude) { - await tx - .update(integrationClaudeModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationClaudeModel.id, integrationClaude.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "claude", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationClaudeModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationClaudeService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -95,14 +49,6 @@ export const connectClaudeAction = workspaceActionClient aiProviders.enum.claude, ) - await auditService.record({ - workspaceId, - action: integrationClaude ? "update" : "connect", - detail: integrationClaude - ? "updated the Claude integration configuration" - : "connected a new Claude integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-claude/actions/update.action.ts b/apps/builder/src/features/integration-claude/actions/update.action.ts index 7c46f626f5..dc92cf1b83 100644 --- a/apps/builder/src/features/integration-claude/actions/update.action.ts +++ b/apps/builder/src/features/integration-claude/actions/update.action.ts @@ -2,9 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationClaudeModel } from "@chatbotx.io/database/schema" +import { integrationClaudeService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -26,26 +24,11 @@ export const updateIntegrationClaudeAction = workspaceActionClient parsedInput: UpdateClaudeRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationClaude = await findOrFail({ - table: integrationClaudeModel, - where: { workspaceId }, - message: "Integration Claude not found", - }) - - await db - .update(integrationClaudeModel) - .set(parsedInput) - .where(eq(integrationClaudeModel.id, integrationClaude.id)) + await integrationClaudeService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, aiProviders.enum.claude, ) - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the Claude integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-claude/queries/index.ts b/apps/builder/src/features/integration-claude/queries/index.ts index 0c2870ee0c..e61e83f785 100644 --- a/apps/builder/src/features/integration-claude/queries/index.ts +++ b/apps/builder/src/features/integration-claude/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationClaudeService } from "@chatbotx.io/business" import type { IntegrationClaudeResource } from "../schema/resource" export const findIntegrationClaude = async ({ @@ -6,8 +6,4 @@ export const findIntegrationClaude = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationClaudeModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null + (await integrationClaudeService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-deepseek/actions/connect.action.ts b/apps/builder/src/features/integration-deepseek/actions/connect.action.ts index 58db5ef6cd..cf49438bfe 100644 --- a/apps/builder/src/features/integration-deepseek/actions/connect.action.ts +++ b/apps/builder/src/features/integration-deepseek/actions/connect.action.ts @@ -2,14 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationDeepseekModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationDeepSeekService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -44,49 +37,12 @@ export const connectDeepSeekAction = workspaceActionClient }) } - const integrationDeepseek = - await db.query.integrationDeepseekModel.findFirst({ - where: { workspaceId }, - }) - - await db.transaction(async (tx) => { - if (integrationDeepseek) { - await tx - .update(integrationDeepseekModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationDeepseekModel.id, integrationDeepseek.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "deepseek", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationDeepseekModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationDeepSeekService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -94,14 +50,6 @@ export const connectDeepSeekAction = workspaceActionClient aiProviders.enum.deepseek, ) - await auditService.record({ - workspaceId, - action: integrationDeepseek ? "update" : "connect", - detail: integrationDeepseek - ? "updated the DeepSeek integration configuration" - : "connected a new DeepSeek integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-deepseek/actions/update.action.ts b/apps/builder/src/features/integration-deepseek/actions/update.action.ts index 73cccc8cd0..fbe705ab12 100644 --- a/apps/builder/src/features/integration-deepseek/actions/update.action.ts +++ b/apps/builder/src/features/integration-deepseek/actions/update.action.ts @@ -2,9 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationDeepseekModel } from "@chatbotx.io/database/schema" +import { integrationDeepSeekService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -26,26 +24,11 @@ export const updateIntegrationDeepSeekAction = workspaceActionClient parsedInput: UpdateDeepSeekRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationDeepseek = await findOrFail({ - table: integrationDeepseekModel, - where: { workspaceId }, - message: "Integration DeepSeek not found", - }) - - await db - .update(integrationDeepseekModel) - .set(parsedInput) - .where(eq(integrationDeepseekModel.id, integrationDeepseek.id)) + await integrationDeepSeekService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, aiProviders.enum.deepseek, ) - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the DeepSeek integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-deepseek/queries/index.ts b/apps/builder/src/features/integration-deepseek/queries/index.ts index 00739478f1..fd0476d13d 100644 --- a/apps/builder/src/features/integration-deepseek/queries/index.ts +++ b/apps/builder/src/features/integration-deepseek/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationDeepSeekService } from "@chatbotx.io/business" import type { IntegrationDeepseekResource } from "../schema/resource" export const findIntegrationDeepSeek = async ({ @@ -6,6 +6,4 @@ export const findIntegrationDeepSeek = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationDeepseekModel.findFirst({ - where: { workspaceId }, - })) ?? null + (await integrationDeepSeekService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-gemini/actions/connect.action.ts b/apps/builder/src/features/integration-gemini/actions/connect.action.ts index d52e6930c6..d8b336e093 100644 --- a/apps/builder/src/features/integration-gemini/actions/connect.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/connect.action.ts @@ -1,21 +1,15 @@ "use server" +import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationGeminiModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationGeminiService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" import { workspaceActionClient } from "@/lib/safe-action" -import { verifyGeminiApiKey } from "../lib" import { type ConnectGeminiRequest, connectGeminiRequest, @@ -34,7 +28,12 @@ export const connectGeminiAction = workspaceActionClient }) => { const t = await getTranslations() - if (!(await verifyGeminiApiKey(parsedInput.apiKey))) { + if ( + !(await verifyAiProviderApiKey( + aiProviders.enum.gemini, + parsedInput.apiKey, + )) + ) { return returnValidationErrors(connectGeminiRequest, { apiKey: { _errors: [t("validation.invalidApiKey")], @@ -42,63 +41,18 @@ export const connectGeminiAction = workspaceActionClient }) } - const integrationGemini = await db.query.integrationGeminiModel.findFirst( - { - where: { - workspaceId, - }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationGemini) { - await tx - .update(integrationGeminiModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationGeminiModel.id, integrationGemini.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - workspaceId, - integrationType: "gemini", - id: createId(), - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationGeminiModel).values({ - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - id: createId(), - integrationId: integration.id, - }) - } + await integrationGeminiService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) - await aiIntegrationService.invalidateCache(workspaceId, "gemini") - - await auditService.record({ + await aiIntegrationService.invalidateCache( workspaceId, - action: integrationGemini ? "update" : "connect", - detail: integrationGemini - ? "updated the Gemini integration configuration" - : "connected a new Gemini integration", - }) + aiProviders.enum.gemini, + ) return }, diff --git a/apps/builder/src/features/integration-gemini/actions/update.action.ts b/apps/builder/src/features/integration-gemini/actions/update.action.ts index 8a5509ffe1..00038261fb 100644 --- a/apps/builder/src/features/integration-gemini/actions/update.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/update.action.ts @@ -1,8 +1,6 @@ "use server" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationGeminiModel } from "@chatbotx.io/database/schema" +import { integrationGeminiService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -24,23 +22,8 @@ export const updateGeminiAction = workspaceActionClient parsedInput: UpdateGeminiRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationGemini = await findOrFail({ - table: integrationGeminiModel, - where: { workspaceId }, - message: "Integration Gemini not found", - }) - - await db - .update(integrationGeminiModel) - .set(parsedInput) - .where(eq(integrationGeminiModel.id, integrationGemini.id)) + await integrationGeminiService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache(workspaceId, "gemini") - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the Gemini integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-gemini/lib/index.ts b/apps/builder/src/features/integration-gemini/lib/index.ts deleted file mode 100644 index 7760c1767e..0000000000 --- a/apps/builder/src/features/integration-gemini/lib/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ky from "ky" - -export async function verifyGeminiApiKey(apiKey: string) { - try { - await ky.get( - `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, - ) - return true - } catch { - return false - } -} diff --git a/apps/builder/src/features/integration-gemini/queries/index.ts b/apps/builder/src/features/integration-gemini/queries/index.ts index a174bfa0ad..fe2bd5af90 100644 --- a/apps/builder/src/features/integration-gemini/queries/index.ts +++ b/apps/builder/src/features/integration-gemini/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationGeminiService } from "@chatbotx.io/business" import type { IntegrationGeminiResource } from "../schema/resource" export const findIntegrationGemini = async ({ @@ -6,8 +6,4 @@ export const findIntegrationGemini = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationGeminiModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null + (await integrationGeminiService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts index e790fe7422..4e35fb5a32 100644 --- a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts @@ -1,11 +1,7 @@ "use server" +import { integrationGoogleSheetService } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { - integrationGoogleSheetsModel, - integrationModel, -} from "@chatbotx.io/database/schema" import { type GoogleSheetsAuthValue, integration as integrationGoogleSheets, @@ -15,9 +11,9 @@ import { workspaceIdrequestParams, } from "@/features/common/schema" import { logger } from "@/lib/log" -import { authActionClient } from "@/lib/safe-action" +import { workspaceActionClientAllowExpired } from "@/lib/safe-action" -export const disconnectGoogleSheetsAction = authActionClient +export const disconnectGoogleSheetsAction = workspaceActionClientAllowExpired .bindArgsSchemas(workspaceIdrequestParams) .action( async ({ @@ -25,13 +21,8 @@ export const disconnectGoogleSheetsAction = authActionClient }: { bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const googleSheets = await findOrFail({ - table: integrationGoogleSheetsModel, - where: { - workspaceId, - }, - message: "Integration Google Sheets not found", - }) + const googleSheets = + await integrationGoogleSheetService.findByWorkspaceIdOrFail(workspaceId) try { await integrationGoogleSheets.disconnect?.( googleSheets.auth as GoogleSheetsAuthValue, @@ -43,11 +34,7 @@ export const disconnectGoogleSheetsAction = authActionClient ) } - await db.transaction(async (tx) => { - await tx - .delete(integrationModel) - .where(eq(integrationModel.id, googleSheets.integrationId)) - }) + await integrationGoogleSheetService.disconnect(googleSheets.integrationId) await auditService.record({ workspaceId, 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 311c3939b7..7cd4dc8ac0 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,13 @@ import { coexistService, inboxService, + instagramIntegrationService, messengerIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { db } 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 +21,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" @@ -91,9 +91,10 @@ export const disconnectInstagram = async (ctx: { tx, ) - await tx - .delete(integrationInstagramModel) - .where(eq(integrationInstagramModel.id, integrationInstagram.id)) + await instagramIntegrationService.disconnect({ + id: integrationInstagram.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationInstagram.inboxId, 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..ced8826341 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 { db, 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, @@ -59,14 +59,15 @@ export const updateInstagramAction = workspaceActionClient id, }) - await tx - .update(integrationInstagramModel) - .set({ + await instagramIntegrationService.updateProfileFields( + { id }, + { welcomeFlowId: parsedInput.welcomeFlowId, conversationStarters: parsedInput.conversationStarters, persistentMenus: parsedInput.persistentMenus, - }) - .where(eq(integrationInstagramModel.id, id)) + }, + tx, + ) if (integrationInstagramData) { const auth = integrationInstagramData.auth as InstagramAuthValue diff --git a/apps/builder/src/features/integration-instagram/queries/index.ts b/apps/builder/src/features/integration-instagram/queries/index.ts index ea34de5f19..78f76bed4e 100644 --- a/apps/builder/src/features/integration-instagram/queries/index.ts +++ b/apps/builder/src/features/integration-instagram/queries/index.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { instagramIntegrationService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationInstagramModel } from "@chatbotx.io/database/schema" import type { IntegrationInstagramModel } from "@chatbotx.io/database/types" @@ -16,14 +17,7 @@ export const listIntegrationInstagrams = async ({ }: { workspaceId: string }): Promise<{ data: IntegrationInstagramModel[] }> => { - const data = await db.query.integrationInstagramModel.findMany({ - where: { - workspaceId, - }, - orderBy: { - createdAt: "asc", - }, - }) + const data = await instagramIntegrationService.listByWorkspaceId(workspaceId) return { data } } diff --git a/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts b/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts index bcd508f4ab..b02f4152b5 100644 --- a/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts +++ b/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts @@ -37,43 +37,16 @@ vi.mock("@/features/workspace-members/queries", () => ({ })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/client -// Chainable builder: db.update(model).set(…).where(…).returning() +// Mock @chatbotx.io/database/client — findOrFail is still reached by the +// workspaceActionClient auth chain. // --------------------------------------------------------------------------- -const returningResult: { current: { syncTagEnabledAt: Date | null }[] } = { - current: [], -} - -const dbUpdateBuilder = { - set: vi.fn(), - where: vi.fn(), - returning: vi.fn(), -} - vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: vi.fn(), - }, findOrFail: vi.fn(), isDatabaseError: vi.fn(() => false), - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/schema -// --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationMessengerModel: { - id: "id", - workspaceId: "workspaceId", - syncTagEnabledAt: "syncTagEnabledAt", - }, - userModel: { id: "id" }, -})) - -// --------------------------------------------------------------------------- -// Mock @chatbotx.io/business (isPlatformAdmin) and errors +// Mock @chatbotx.io/business (isPlatformAdmin, messengerIntegrationService) and errors // // This factory mock enumerates exports, so it must cover everything // `workspaceActionClient` reaches — not just what this action calls directly. @@ -82,6 +55,8 @@ vi.mock("@chatbotx.io/database/schema", () => ({ // unrelated failure. `isWorkspaceScheduledForDeletion` is the deletion gate in // `lib/safe-action.ts`; `false` = an active workspace, this action's precondition. // --------------------------------------------------------------------------- +const updateTagSync = vi.fn() + vi.mock("@chatbotx.io/business", () => ({ isPlatformAdmin: vi.fn(async () => false), isWorkspaceScheduledForDeletion: vi.fn(() => false), @@ -95,6 +70,7 @@ vi.mock("@chatbotx.io/business", () => ({ isSupportSession: false, } }), + messengerIntegrationService: { updateTagSync }, })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -129,7 +105,7 @@ const { toggleMessengerTagSyncAction } = await import( "../toggle-tag-sync.action" ) const { invalidateCacheByTags } = await import("@chatbotx.io/redis") -const { db, findOrFail } = await import("@chatbotx.io/database/client") +const { findOrFail } = await import("@chatbotx.io/database/client") const { getCurrentUserId } = await import("@/lib/auth/utils") const { getAllWorkspaceMembers } = await import( "@/features/workspace-members/queries" @@ -138,7 +114,6 @@ const { getAllWorkspaceMembers } = await import( const invalidateCacheByTagsMock = invalidateCacheByTags as ReturnType< typeof vi.fn > -const dbUpdate = db.update as ReturnType const findOrFailMock = findOrFail as ReturnType const getCurrentUserIdMock = getCurrentUserId as ReturnType const getAllWorkspaceMembersMock = getAllWorkspaceMembers as ReturnType< @@ -173,52 +148,29 @@ describe("toggleMessengerTagSyncAction", () => { workspaceIds: [WORKSPACE_ID], }) - // Re-wire the chainable DB builder - dbUpdateBuilder.set.mockReturnValue(dbUpdateBuilder) - dbUpdateBuilder.where.mockReturnValue(dbUpdateBuilder) - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - dbUpdate.mockReturnValue(dbUpdateBuilder) + updateTagSync.mockResolvedValue(null) }) // ── enabled: true ────────────────────────────────────────────────────────── describe("enabled: true", () => { - test("sets syncTagEnabledAt to a Date instance (not null)", async () => { + test("returns the Date instance from the service (not null)", async () => { const now = new Date() - returningResult.current = [{ syncTagEnabledAt: now }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(now) const result = await invokeAction(true) - expect(dbUpdate).toHaveBeenCalledTimes(1) - - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeInstanceOf(Date) - expect(setArg.syncTagEnabledAt).not.toBeNull() - - // Return value exposes syncTagEnabledAt from the DB row + expect(updateTagSync).toHaveBeenCalledTimes(1) + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: true, + }) expect(result?.data?.syncTagEnabledAt).toBeInstanceOf(Date) }) - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - returningResult.current = [{ syncTagEnabledAt: new Date() }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - - await invokeAction(true) - - expect(dbUpdateBuilder.where).toHaveBeenCalledTimes(1) - // Our and() mock spreads its args into an array — the array should contain - // exactly two eq() predicate results (one per field). - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) - test("calls invalidateCacheByTags with the workspace-scoped messenger key", async () => { - returningResult.current = [{ syncTagEnabledAt: new Date() }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(new Date()) await invokeAction(true) @@ -232,21 +184,20 @@ describe("toggleMessengerTagSyncAction", () => { // ── enabled: false ───────────────────────────────────────────────────────── describe("enabled: false", () => { - test("sets syncTagEnabledAt to null", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + test("passes enabled: false to the service", async () => { + updateTagSync.mockResolvedValue(null) await invokeAction(false) - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeNull() + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: false, + }) }) test("calls invalidateCacheByTags with the workspace-scoped messenger key", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(null) await invokeAction(false) @@ -254,35 +205,21 @@ describe("toggleMessengerTagSyncAction", () => { `workspaces:${WORKSPACE_ID}#messengers`, ]) }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - - await invokeAction(false) - - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) }) // ── no matching row ──────────────────────────────────────────────────────── - describe("no matching row (returning empty array)", () => { + describe("no matching row (service returns null)", () => { test("returns { syncTagEnabledAt: null } without throwing", async () => { - returningResult.current = [] - dbUpdateBuilder.returning.mockResolvedValue([]) + updateTagSync.mockResolvedValue(null) const result = await invokeAction(true) - // updated[0] is undefined → falls back to null via `?? null` expect(result?.data?.syncTagEnabledAt).toBeNull() }) test("still calls invalidateCacheByTags even when no row was updated", async () => { - returningResult.current = [] - dbUpdateBuilder.returning.mockResolvedValue([]) + updateTagSync.mockResolvedValue(null) await invokeAction(false) 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 c0d811afc2..1342c7fe6e 100644 --- a/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts +++ b/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts @@ -2,16 +2,12 @@ import { coexistService, inboxService, 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 { db } from "@chatbotx.io/database/client" import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" -import { - integrationMessengerModel, - tagChannelModel, -} from "@chatbotx.io/database/schema" import { isDisconnectSafeError, type MessengerAuthValue, @@ -25,17 +21,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 = @@ -90,16 +86,6 @@ export const disconnectMessenger = async (ctx: { 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( @@ -111,9 +97,10 @@ export const disconnectMessenger = async (ctx: { tx, ) - await tx - .delete(integrationMessengerModel) - .where(eq(integrationMessengerModel.id, integrationMessenger.id)) + await messengerIntegrationService.disconnect({ + id: integrationMessenger.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationMessenger.inboxId, diff --git a/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts b/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts index 16d2d93cc1..c1f64c7d05 100644 --- a/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts +++ b/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts @@ -1,7 +1,6 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { messengerIntegrationService } from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -16,22 +15,13 @@ export const toggleMessengerTagSyncAction = workspaceActionClient parsedInput: { enabled }, } = props - const updated = await db - .update(integrationMessengerModel) - .set({ syncTagEnabledAt: enabled ? new Date() : null }) - .where( - and( - eq(integrationMessengerModel.id, integrationId), - eq(integrationMessengerModel.workspaceId, workspaceId), - ), - ) - .returning({ - syncTagEnabledAt: integrationMessengerModel.syncTagEnabledAt, - }) + const syncTagEnabledAt = await messengerIntegrationService.updateTagSync({ + workspaceId, + integrationId, + enabled, + }) await invalidateCacheByTags([`workspaces:${workspaceId}#messengers`]) - return { - syncTagEnabledAt: updated[0]?.syncTagEnabledAt ?? null, - } + return { syncTagEnabledAt } }) 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..c399042f95 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,14 @@ "use server" -import { buildContext, type IntegrationContext } from "@chatbotx.io/business" +import { + buildContext, + type IntegrationContext, + 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 { db } from "@chatbotx.io/database/client" import type { MessengerPersona } from "@chatbotx.io/database/partials" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel, WorkspaceModel, @@ -83,14 +86,15 @@ export const updateMessenger = async ( ) } - await tx - .update(integrationMessengerModel) - .set({ + await messengerIntegrationService.updateProfileFields( + { id: ctx.id }, + { ...parsedInput, personas: syncedPersonas, personaId: defaultPersona?.facebookPersonaId ?? null, - }) - .where(eq(integrationMessengerModel.id, ctx.id)) + }, + tx, + ) botContext = await buildContext({ workspaceId: ctx.workspace.id, 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..4b0c30a56b 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,9 @@ "use server" -import { db, inArray } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { + messengerIntegrationService, + messengerMessageTemplateService, +} 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" @@ -140,14 +142,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 messengerMessageTemplateService.findByIdForIntegration({ + id: templateId, + integrationMessengerId: sourceIntegrationMessengerId, + workspaceId, }) if (!sourceTemplate) { @@ -156,18 +154,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 messengerIntegrationService.findByIdForWorkspace({ + id: sourceIntegrationMessengerId, + workspaceId, }) // 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 messengerIntegrationService.findByIds( + 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..9f706e3e29 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,9 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" +import { + messengerIntegrationService, + messengerMessageTemplateService, +} from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,29 +20,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 messengerMessageTemplateService.delete({ + id: templateId, + integrationMessengerId, + }) 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..74fc6ce96b 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,15 @@ "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, + messengerMessageTemplateService, +} 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 +65,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 messengerMessageTemplateService.syncFromMeta({ + integrationMessengerId: integrationMessenger.id, + templates, + isPartialSync, }) } @@ -138,14 +79,14 @@ 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/message-templates/queries/index.ts b/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts index a7a2691a57..aa045ca036 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts @@ -1,138 +1 @@ -import { - and, - type DatabaseClient, - db, - eq, - ilike, -} from "@chatbotx.io/database/client" -import type { MessengerTemplateStatus } from "@chatbotx.io/database/partials" -import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, -} from "@chatbotx.io/database/utils" -import type { ListMessengerMessageTemplatesResponse } from "@/features/integration-messenger/message-templates/schema/query" - -type MessengerMessageTemplateListWhere = { - workspaceId: string - inboxId?: string - integrationMessengerId?: string - status?: MessengerTemplateStatus - name?: string -} - -async function resolveIntegrationMessengerId({ - tx, - where, -}: { - tx: DatabaseClient - where: MessengerMessageTemplateListWhere -}) { - let resolvedIntegrationMessengerId = where.integrationMessengerId - - if (!resolvedIntegrationMessengerId && where.inboxId) { - const integration = await tx.query.integrationMessengerModel.findFirst({ - where: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - columns: { id: true }, - }) - resolvedIntegrationMessengerId = integration?.id - } - - return resolvedIntegrationMessengerId -} - -export const messengerMessageTemplateService = { - list: async (props: { - tx?: DatabaseClient - where: MessengerMessageTemplateListWhere - }): Promise => { - const { tx = db, where } = props - - // Resolve integrationMessengerId from inboxId when only inboxId is given. - // Relying on nested relational filtering for inboxId is fragile and ORM- - // version-sensitive because messengerMessageTemplateModel has no direct - // inboxId column. - const resolvedIntegrationMessengerId = await resolveIntegrationMessengerId({ - tx, - where, - }) - - return tx.query.messengerMessageTemplateModel.findMany({ - where: { - status: where.status, - integrationMessengerId: resolvedIntegrationMessengerId, - integrationMessenger: { - workspaceId: where.workspaceId, - }, - }, - with: { - integrationMessenger: true, - }, - orderBy: { id: "desc" }, - }) - }, - listPaginated: async (props: { - tx?: DatabaseClient - where: MessengerMessageTemplateListWhere - page?: number - perPage?: number - }) => { - const { tx = db, where } = props - const resolvedIntegrationMessengerId = await resolveIntegrationMessengerId({ - tx, - where, - }) - const queryWhere = { - name: where.name ? { ilike: likeContains(where.name) } : undefined, - status: where.status, - integrationMessengerId: resolvedIntegrationMessengerId, - integrationMessenger: { - workspaceId: where.workspaceId, - }, - } - const pagination = getPaginationWithDefaults({ - page: props.page, - perPage: props.perPage, - }) - - const [data, total] = await Promise.all([ - tx.query.messengerMessageTemplateModel.findMany({ - where: queryWhere, - with: { - integrationMessenger: true, - }, - orderBy: { id: "desc" }, - limit: pagination.limit, - offset: pagination.offset, - }), - tx.$count( - messengerMessageTemplateModel, - and( - where.name - ? ilike( - messengerMessageTemplateModel.name, - likeContains(where.name), - ) - : undefined, - where.status - ? eq(messengerMessageTemplateModel.status, where.status) - : undefined, - resolvedIntegrationMessengerId - ? eq( - messengerMessageTemplateModel.integrationMessengerId, - resolvedIntegrationMessengerId, - ) - : undefined, - ), - ), - ]) - - return { - data, - pageCount: Math.max(1, Math.ceil(total / pagination.limit)), - } - }, -} +export { messengerMessageTemplateService } from "@chatbotx.io/business" diff --git a/apps/builder/src/features/integration-messenger/queries/index.ts b/apps/builder/src/features/integration-messenger/queries/index.ts index 2de7df45df..6cdfa9bf49 100644 --- a/apps/builder/src/features/integration-messenger/queries/index.ts +++ b/apps/builder/src/features/integration-messenger/queries/index.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" @@ -10,12 +11,7 @@ export const findIntegrationMessenger = async ( 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.listByWorkspaceIdOrId(input) return { data } } diff --git a/apps/builder/src/features/integration-openai/actions/connect.action.ts b/apps/builder/src/features/integration-openai/actions/connect.action.ts index 839e936329..b226942876 100644 --- a/apps/builder/src/features/integration-openai/actions/connect.action.ts +++ b/apps/builder/src/features/integration-openai/actions/connect.action.ts @@ -2,28 +2,21 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationModel, - integrationOpenaiModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationOpenAIService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" -import { authActionClient } from "@/lib/safe-action" -import { verifyOpenAIApiKey } from "../lib" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" +import { workspaceActionClient } from "@/lib/safe-action" import { type ConnectOpenAISchema, connectOpenAISchema, } from "../schema/request" -export const connectOpenAIAction = authActionClient +export const connectOpenAIAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(connectOpenAISchema) .action( @@ -36,7 +29,12 @@ export const connectOpenAIAction = authActionClient }) => { const t = await getTranslations() - if (!(await verifyOpenAIApiKey(parsedInput.apiKey))) { + if ( + !(await verifyAiProviderApiKey( + aiProviders.enum.openai, + parsedInput.apiKey, + )) + ) { return returnValidationErrors(connectOpenAISchema, { apiKey: { _errors: [t("validation.invalidApiKey")], @@ -44,52 +42,12 @@ export const connectOpenAIAction = authActionClient }) } - const integrationOpenAI = await db.query.integrationOpenaiModel.findFirst( - { - where: { - workspaceId, - }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationOpenAI) { - await tx - .update(integrationOpenaiModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationOpenaiModel.id, integrationOpenAI.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "openai", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationOpenaiModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationOpenAIService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -97,14 +55,6 @@ export const connectOpenAIAction = authActionClient aiProviders.enum.openai, ) - await auditService.record({ - workspaceId, - action: integrationOpenAI ? "update" : "connect", - detail: integrationOpenAI - ? "updated the OpenAI integration configuration" - : "connected a new OpenAI integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-openai/actions/update-openai.action.ts b/apps/builder/src/features/integration-openai/actions/update-openai.action.ts index da229cc21c..e7230177d9 100644 --- a/apps/builder/src/features/integration-openai/actions/update-openai.action.ts +++ b/apps/builder/src/features/integration-openai/actions/update-openai.action.ts @@ -1,8 +1,6 @@ "use server" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationOpenaiModel } from "@chatbotx.io/database/schema" +import { integrationOpenAIService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" import { @@ -29,29 +27,9 @@ export const updateIntegrationOpenAI = async ( }, parsedInput: UpdateOpenAIRequest, ) => { - const integrationOpenAI = await findOrFail({ - table: integrationOpenaiModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Integration OpenAI not found", - }) - - const result = await db - .update(integrationOpenaiModel) - .set(parsedInput) - .where(eq(integrationOpenaiModel.id, integrationOpenAI.id)) - .returning() - .then((result) => result[0]) + const result = await integrationOpenAIService.update(ctx, parsedInput) await aiIntegrationService.invalidateCache(ctx.workspaceId, "openai") - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail: "updated the OpenAI integration configuration", - }) - return result } diff --git a/apps/builder/src/features/integration-openai/lib/index.ts b/apps/builder/src/features/integration-openai/lib/index.ts deleted file mode 100644 index 2d5e41323c..0000000000 --- a/apps/builder/src/features/integration-openai/lib/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import ky from "ky" - -export async function verifyOpenAIApiKey(apiKey: string) { - try { - await ky.get("https://api.openai.com/v1/models", { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - return true - } catch { - return false - } -} diff --git a/apps/builder/src/features/integration-openai/queries/index.ts b/apps/builder/src/features/integration-openai/queries/index.ts index d2d7527663..26d50b8980 100644 --- a/apps/builder/src/features/integration-openai/queries/index.ts +++ b/apps/builder/src/features/integration-openai/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationOpenAIService } from "@chatbotx.io/business" import type { IntegrationOpenAIResource } from "../schema/request" export const findIntegrationOpenAI = async ({ @@ -8,11 +8,7 @@ export const findIntegrationOpenAI = async ({ }): Promise<{ data: IntegrationOpenAIResource | null }> => { - const data = await db.query.integrationOpenaiModel.findFirst({ - where: { - workspaceId, - }, - }) + const data = await integrationOpenAIService.findByWorkspaceId(workspaceId) return { data: data ?? null, diff --git a/apps/builder/src/features/integration-smtp/queries/index.ts b/apps/builder/src/features/integration-smtp/queries/index.ts index 6c04ed720e..1632258561 100644 --- a/apps/builder/src/features/integration-smtp/queries/index.ts +++ b/apps/builder/src/features/integration-smtp/queries/index.ts @@ -1,6 +1,7 @@ "use server" -import { db, findOrFail } from "@chatbotx.io/database/client" +import { integrationSmtpService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationSmtpModel } from "@chatbotx.io/database/schema" import type { IntegrationSmtpModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -16,14 +17,7 @@ export const listIntegrationSmtps = async (input: { }): Promise<{ data: IntegrationSmtpResource[] }> => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const data = await db.query.integrationSmtpModel.findMany({ - where: { - workspaceId: input.workspaceId, - }, - orderBy: { - createdAt: "desc", - }, - }) + const data = await integrationSmtpService.listByWorkspaceId(input.workspaceId) return { data: data.map(({ id, name, fromAddress }) => ({ diff --git a/apps/builder/src/features/integration-smtp/services/smtp.service.ts b/apps/builder/src/features/integration-smtp/services/smtp.service.ts index c7da8375dc..d734c55507 100644 --- a/apps/builder/src/features/integration-smtp/services/smtp.service.ts +++ b/apps/builder/src/features/integration-smtp/services/smtp.service.ts @@ -1,17 +1,7 @@ -import { - connectChannelIntegration, - inboxService, - workspaceService, -} from "@chatbotx.io/business" -import { auditService, isSameJsonValue } from "@chatbotx.io/business/audit" +import { integrationSmtpService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { channelTypes } from "@chatbotx.io/database/partials" -import { integrationSmtpModel } from "@chatbotx.io/database/schema" -import type { SmtpAuthValue } from "@chatbotx.io/integration-smtp" import { smtpHostMap } from "@chatbotx.io/integration-smtp" import { createSmtpTransporter } from "@chatbotx.io/mail/transport" -import { createId } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import type { CreateSmtpRequest, UpdateSmtpRequest } from "../schema/mutation" @@ -39,65 +29,28 @@ export async function verifySmtpConnection(input: CreateSmtpRequest) { } } +const resolveHostAndPort = (input: { + provider: CreateSmtpRequest["provider"] + host: string + port: number +}) => { + if (input.provider !== "other") { + return smtpHostMap[input.provider] + } + return { host: input.host, port: input.port } +} + export async function createSmtp( workspaceId: string, input: CreateSmtpRequest, ) { - let { host, port, fromAddress, ...rest } = input await verifySmtpConnection(input) - - if (input.provider !== "other") { - const defaultHostAndPort = smtpHostMap[input.provider] - host = defaultHostAndPort.host - port = defaultHostAndPort.port - } - - const workspace = await workspaceService.find({ where: { id: workspaceId } }) - if (!workspace) { - throw new ChatbotXException("Workspace not found") - } - - const { inbox, wasCreated } = await db.transaction(async (tx) => { - const smtpId = createId() - const name = input.username - - return await connectChannelIntegration({ - tx, - ownerId: workspace.ownerId, - inboxData: { - id: smtpId, - workspaceId, - channel: channelTypes.enum.smtp, - name, - sourceId: smtpId, - }, - insertIntegration: async (inboxId) => { - await tx.insert(integrationSmtpModel).values({ - id: smtpId, - name, - workspaceId, - inboxId, - fromAddress, - auth: { - authType: "custom" as const, - ...rest, - host, - port, - }, - }) - }, - }) + const { host, port } = resolveHostAndPort(input) + return await integrationSmtpService.create(workspaceId, { + ...input, + host, + port, }) - - if (wasCreated) { - await auditService.record({ - workspaceId, - action: "connect", - detail: `connected a new SMTP channel (#${inbox.id})`, - }) - } - - return inbox } export async function updateSmtp( @@ -106,93 +59,14 @@ export async function updateSmtp( input: UpdateSmtpRequest, ) { await verifySmtpConnection(input) - - const integration = await findOrFail({ - table: integrationSmtpModel, - where: { id, workspaceId }, - message: "SMTP integration not found", - }) - - const currentAuth = integration.auth as SmtpAuthValue - const provider = input.provider ?? currentAuth.provider - - let host = input.host || currentAuth.host - let port = input.port || currentAuth.port - - if (provider !== "other") { - const defaults = smtpHostMap[provider] - host = defaults.host - port = defaults.port - } - - const updatedAuth: SmtpAuthValue = { - authType: "custom", - provider, + const { host, port } = resolveHostAndPort(input) + return await integrationSmtpService.update(workspaceId, id, { + ...input, host, port, - username: input.username ?? currentAuth.username, - password: input.password ?? currentAuth.password, - } - - const name = input.username ?? integration.name - - const updated = await db - .update(integrationSmtpModel) - .set({ auth: updatedAuth, name, fromAddress: input.fromAddress }) - .where(eq(integrationSmtpModel.id, integration.id)) - .returning() - .then((result) => result[0]) - - const hasChanged = !isSameJsonValue( - { auth: updatedAuth, name, fromAddress: input.fromAddress }, - { - auth: currentAuth, - name: integration.name, - fromAddress: integration.fromAddress, - }, - ) - - if (hasChanged) { - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the SMTP channel configuration", - }) - } - - return updated + }) } export async function deleteSmtp(workspaceId: string, id: string) { - const [integration, workspace] = await Promise.all([ - findOrFail({ - table: integrationSmtpModel, - where: { - id, - workspaceId, - }, - message: "SMTP integration not found", - }), - workspaceService.findById({ id: workspaceId }), - ]) - - await db.transaction(async (tx) => { - await tx - .delete(integrationSmtpModel) - .where(eq(integrationSmtpModel.id, integration.id)) - - await inboxService.disconnect({ - inboxId: integration.inboxId, - ownerId: workspace.ownerId, - workspaceId, - reason: "manual", - tx, - }) - }) - - await auditService.record({ - workspaceId, - action: "disconnect", - detail: `disconnected the SMTP channel (#${integration.id})`, - }) + await integrationSmtpService.delete(workspaceId, id) } diff --git a/apps/builder/src/features/integration-telegram/actions/connect.action.ts b/apps/builder/src/features/integration-telegram/actions/connect.action.ts index dc017af571..ae2578e160 100644 --- a/apps/builder/src/features/integration-telegram/actions/connect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/connect.action.ts @@ -1,18 +1,15 @@ "use server" import { - connectChannelIntegration, + hasWorkspaceAccess, + telegramIntegrationService, 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 { integrationTypes } from "@chatbotx.io/database/partials" -import { integrationTelegramModel } from "@chatbotx.io/database/schema" import type { UserModel } from "@chatbotx.io/database/types" -import type { TelegramAuthValue } from "@chatbotx.io/integration-telegram" -import { createId } from "@chatbotx.io/utils" import { redirect } from "next/navigation" import { isCloud } from "@/env" import { integrations } from "@/integration" @@ -45,6 +42,9 @@ export const connectTelegramAction = authActionClient // Resolve ownerId before the transaction to avoid an extra read inside it let ownerId = ctx.user.id if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) @@ -67,10 +67,6 @@ export const connectTelegramAction = authActionClient } const result = await db.transaction(async (tx) => { - const auth: TelegramAuthValue = { - authType: "secretText", - secretText: parsedInput.botToken, - } let createdWorkspace = false if (!workspaceId) { @@ -87,28 +83,15 @@ export const connectTelegramAction = authActionClient createdWorkspace = true } - const integrationId = createId() - const { wasCreated } = await connectChannelIntegration({ - tx, - ownerId, - inboxData: { - id: createId(), + const { integrationId, wasCreated } = + await telegramIntegrationService.connect({ + tx, + ownerId, workspaceId: workspaceId as string, - name: botData.username, - channel: integrationTypes.enum.telegram, - sourceId: botData.id, - }, - insertIntegration: async (inboxId) => { - await tx.insert(integrationTelegramModel).values({ - id: integrationId, - inboxId, - workspaceId: workspaceId as string, - botId: botData.id, - name: botData.username, - auth, - }) - }, - }) + botId: botData.id, + botUsername: botData.username, + botToken: parsedInput.botToken, + }) // Register webhook URL with Telegram const webhookUrl = buildBrokerCallbackUrl( diff --git a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts index 0c36448084..05715d064c 100644 --- a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts @@ -1,9 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + inboxService, + telegramIntegrationService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationTelegramModel } from "@chatbotx.io/database/schema" +import { db } from "@chatbotx.io/database/client" import type { TelegramAuthValue } from "@chatbotx.io/integration-telegram" import { type WorkspaceIdAndIdRequestParams, @@ -22,11 +25,7 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationTelegram, workspace] = await Promise.all([ - findOrFail({ - table: integrationTelegramModel, - where: { workspaceId, id }, - message: "Integration Telegram not found", - }), + telegramIntegrationService.findByWorkspaceIdAndId({ workspaceId, id }), workspaceService.findById({ id: workspaceId }), ]) @@ -42,9 +41,10 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired } await db.transaction(async (tx) => { - await tx - .delete(integrationTelegramModel) - .where(eq(integrationTelegramModel.id, integrationTelegram.id)) + await telegramIntegrationService.disconnect({ + id: integrationTelegram.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationTelegram.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-telegram/queries/index.ts b/apps/builder/src/features/integration-telegram/queries/index.ts index ff314d853c..d6e9871dd5 100644 --- a/apps/builder/src/features/integration-telegram/queries/index.ts +++ b/apps/builder/src/features/integration-telegram/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { telegramIntegrationService } from "@chatbotx.io/business" import type { IntegrationTelegramModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -7,12 +7,7 @@ export const listIntegrationTelegrams = async ({ }: { where: Partial> }): Promise<{ data: IntegrationTelegramModel[] }> => { - const data = await db.query.integrationTelegramModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await telegramIntegrationService.listByWorkspaceId(where) return { data } } @@ -25,9 +20,7 @@ export const findIntegrationTelegram = async ({ await assertCurrentUserCanAccessChatbot(workspaceId) return ( - (await db.query.integrationTelegramModel.findFirst({ - where: { workspaceId }, - })) ?? null + (await telegramIntegrationService.findByWorkspaceId(workspaceId)) ?? null ) } @@ -37,6 +30,4 @@ export const findIntegrationTelegramByBotId = async ({ }: { botId: string }): Promise => - (await db.query.integrationTelegramModel.findFirst({ - where: { botId }, - })) ?? null + (await telegramIntegrationService.findByBotId(botId)) ?? null diff --git a/apps/builder/src/features/integration-tiktok/actions/connect.action.ts b/apps/builder/src/features/integration-tiktok/actions/connect.action.ts index d7b505017d..fedb3d3634 100644 --- a/apps/builder/src/features/integration-tiktok/actions/connect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/connect.action.ts @@ -1,14 +1,12 @@ import { - connectChannelIntegration, + tiktokIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" import { db } from "@chatbotx.io/database/client" import type { TiktokCredential } from "@chatbotx.io/database/partials" -import { integrationTiktokModel } from "@chatbotx.io/database/schema" import type { TiktokAuthValue } from "@chatbotx.io/integration-tiktok" -import { createId } from "@chatbotx.io/utils" import { redirect } from "next/navigation" import { integrations } from "@/integration" import { getGuestClientIp } from "@/lib/rate-limit/guest-rate-limit" @@ -38,42 +36,17 @@ export async function connectTiktokHandler({ const displayName = authValue.metadata.displayName const { ownerId } = await workspaceService.findById({ id: workspaceId }) - const integrationId = createId() try { const { wasCreated, integration } = await db.transaction(async (tx) => - connectChannelIntegration({ + tiktokIntegrationService.connect({ tx, ownerId, - inboxData: { - workspaceId, - name: displayName, - channel: "tiktok", - sourceId: authValue.metadata.username, - }, - insertIntegration: async (inboxId) => { - const [integration] = await tx - .insert(integrationTiktokModel) - .values({ - id: integrationId, - inboxId, - workspaceId, - openId, - name: displayName, - auth: authValue, - }) - .onConflictDoUpdate({ - target: [integrationTiktokModel.openId], - set: { - auth: authValue, - name: displayName, - tokenRefreshError: null, - }, - }) - .returning({ id: integrationTiktokModel.id }) - - return integration - }, + workspaceId, + openId, + username: authValue.metadata.username, + displayName, + auth: authValue, }), ) diff --git a/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts b/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts index 961438f208..74e4316b52 100644 --- a/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts @@ -1,9 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + inboxService, + tiktokIntegrationService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationTiktokModel } from "@chatbotx.io/database/schema" +import { db } from "@chatbotx.io/database/client" import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, @@ -19,18 +22,15 @@ export const disconnectTiktokAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationTiktok, workspace] = await Promise.all([ - findOrFail({ - table: integrationTiktokModel, - where: { workspaceId, id }, - message: "Integration TikTok not found", - }), + tiktokIntegrationService.findById({ id, workspaceId }), workspaceService.findById({ id: workspaceId }), ]) await db.transaction(async (tx) => { - await tx - .delete(integrationTiktokModel) - .where(eq(integrationTiktokModel.id, integrationTiktok.id)) + await tiktokIntegrationService.disconnect({ + id: integrationTiktok.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationTiktok.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-tiktok/queries/index.ts b/apps/builder/src/features/integration-tiktok/queries/index.ts index 3bec9e2339..f64e376345 100644 --- a/apps/builder/src/features/integration-tiktok/queries/index.ts +++ b/apps/builder/src/features/integration-tiktok/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { tiktokIntegrationService } from "@chatbotx.io/business" import type { IntegrationTiktokModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -7,12 +7,7 @@ export const listIntegrationTiktoks = async ({ }: { where: Partial> }): Promise<{ data: IntegrationTiktokModel[] }> => { - const data = await db.query.integrationTiktokModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await tiktokIntegrationService.listByWorkspaceId(where) return { data } } @@ -23,11 +18,7 @@ export const findIntegrationTiktok = async ({ }): Promise => { await assertCurrentUserCanAccessChatbot(workspaceId) - return ( - (await db.query.integrationTiktokModel.findFirst({ - where: { workspaceId }, - })) ?? null - ) + return (await tiktokIntegrationService.findByWorkspaceId(workspaceId)) ?? null } export const findIntegrationTiktokByOpenId = async ({ @@ -35,6 +26,4 @@ export const findIntegrationTiktokByOpenId = async ({ }: { openId: string }): Promise => - (await db.query.integrationTiktokModel.findFirst({ - where: { openId }, - })) ?? null + (await tiktokIntegrationService.findByOpenId(openId)) ?? null diff --git a/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts b/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts index 7a11767b61..22bed0339d 100644 --- a/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts +++ b/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts @@ -1,11 +1,14 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + hasWorkspaceAccess, + integrationWebchatService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ensureBrandingMenuEntry } from "@chatbotx.io/business/branding" +import { ChatbotXException } from "@chatbotx.io/business/errors" import { db } from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" import { isCommunity } from "@/env" import { getTenantSettings } from "@/features/tenant/utils" import { authActionClient } from "@/lib/safe-action" @@ -33,6 +36,9 @@ export const createWebchatAction = authActionClient let createdWorkspace = false if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) @@ -51,30 +57,22 @@ export const createWebchatAction = authActionClient createdWorkspace = true } - const webchatId = createId() - const { inbox } = await inboxService.create({ - tx, - ownerId, - data: { - id: webchatId, + const created = await integrationWebchatService.create( + { workspaceId, - channel: "webchat", - name: rest.name, - sourceId: webchatId, + ownerId, + data: { + ...rest, + persistentMenus, + authorizedDomains: authorizedDomains.map((domain) => domain.value), + auth: {}, + customCss: rest.customCss ?? null, + }, }, - }) - - await tx.insert(integrationWebchatModel).values({ - ...rest, - persistentMenus, - id: webchatId, - authorizedDomains: authorizedDomains.map((domain) => domain.value), - workspaceId, - inboxId: inbox.id, - auth: {}, - }) + tx, + ) - return { workspaceId, createdWorkspace, webchatId } + return { workspaceId, createdWorkspace, webchatId: created.id } }) if (result.createdWorkspace) { diff --git a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts index 210cfda6db..bcf2b4feae 100644 --- a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts +++ b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts @@ -1,8 +1,7 @@ "use server" +import { integrationWebchatService } from "@chatbotx.io/business" import { ensureBrandingMenuEntry } from "@chatbotx.io/business/branding" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" import { zodBigintAsString } from "@chatbotx.io/utils" import { isCommunity } from "@/env" import { getTenantSettings } from "@/features/tenant/utils" @@ -32,15 +31,6 @@ export const updateWebchatAction = workspaceActionClient throw new Error("You need to be a super admin to update this webchat") } - const integration = await findOrFail({ - table: integrationWebchatModel, - where: { - id, - workspaceId, - }, - message: "Webchat integration not found", - }) - // Community keeps the "Built with" branding entry; silently re-add it // (same precedent as moveBrandingMenuLast in the messenger action). const persistentMenus = @@ -51,18 +41,15 @@ export const updateWebchatAction = workspaceActionClient }) : rest.persistentMenus - await db.transaction(async (tx) => { - await tx - .update(integrationWebchatModel) - .set({ - ...rest, - persistentMenus, - workspaceId, - welcomeFlowId: welcomeFlowId?.length ? welcomeFlowId : null, - authorizedDomains: authorizedDomains - ? authorizedDomains.map((domain) => domain.value) - : undefined, - }) - .where(eq(integrationWebchatModel.id, integration.id)) - }) + await integrationWebchatService.update( + { workspaceId, id }, + { + ...rest, + persistentMenus, + welcomeFlowId: welcomeFlowId?.length ? welcomeFlowId : null, + authorizedDomains: authorizedDomains + ? authorizedDomains.map((domain) => domain.value) + : undefined, + }, + ) }) diff --git a/apps/builder/src/features/integration-webchat/queries/index.ts b/apps/builder/src/features/integration-webchat/queries/index.ts index cc8736ed64..784e012a91 100644 --- a/apps/builder/src/features/integration-webchat/queries/index.ts +++ b/apps/builder/src/features/integration-webchat/queries/index.ts @@ -1,11 +1,6 @@ "use server" -import { - db, - findOrFail, - relationsFilterToSQL, -} from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" +import { integrationWebchatService } from "@chatbotx.io/business" import type { IntegrationWebchatModel } from "@chatbotx.io/database/types" import { parsePagination } from "@chatbotx.io/database/utils" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -16,26 +11,11 @@ export const listIntegrationWebchats = async ( ) => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const where = { - workspaceId: input.workspaceId, - } - const pagination = parsePagination(input) - const [data, totalRows] = await Promise.all([ - db.query.integrationWebchatModel.findMany({ - where, - orderBy: { - createdAt: "desc", - }, - ...pagination, - }), - pagination?.limit - ? db.$count( - integrationWebchatModel, - relationsFilterToSQL(integrationWebchatModel, where), - ) - : Promise.resolve(1), - ]) + const [data, totalRows] = await integrationWebchatService.listByWorkspaceId({ + workspaceId: input.workspaceId, + pagination, + }) const pageCount = pagination?.limit ? Math.ceil(totalRows / pagination.limit) @@ -46,9 +26,5 @@ export const listIntegrationWebchats = async ( export async function findIntegrationWebchat( where: Pick, ) { - return await findOrFail({ - table: integrationWebchatModel, - where, - message: "Integration webchat not found", - }) + return await integrationWebchatService.findByWorkspaceIdAndId(where) } 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 772d8570b0..176b1644b7 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts @@ -1,19 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import type { DatabaseClient } from "@chatbotx.io/database/client" -import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { - LIVE_RUN_STATUSES, - metaCapiEventRepository, -} from "@chatbotx.io/database/repositories" import { - coexistSyncRunModel, - integrationWhatsappModel, - whatsappCoexistStagingModel, -} from "@chatbotx.io/database/schema" -import type { IntegrationWhatsappModel } from "@chatbotx.io/database/types" + integrationWhatsappService, + workspaceService, +} from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" +import { db, findOrFail } from "@chatbotx.io/database/client" +import { integrationWhatsappModel } from "@chatbotx.io/database/schema" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" import { isRevokedTokenError } from "@chatbotx.io/integration-whatsapp" import { @@ -23,78 +16,6 @@ import { import { integrations } from "@/integration" import { workspaceActionClientAllowExpired } from "@/lib/safe-action" -/** - * Everything one disconnect must abandon or delete, in a single transaction. - * - * Sync history (importedCount / lastSyncedAt / …) is deliberately preserved - * for audit and so a reconnect can resume from the prior watermark; only - * ACTIVE runs are abandoned so the scheduler stops trying to drive them - * forward against a now-missing integration. `LIVE_RUN_STATUSES` includes - * `waiting`: a WhatsApp coexist run parked for more Meta history must be - * abandoned here too, otherwise the scheduler cannot revive it (its staging - * rows are deleted below) and it lingers until the 24h history-window - * timeout closes it. - */ -async function purgeWhatsappIntegration( - tx: DatabaseClient, - { - integrationWhatsapp, - ownerId, - workspaceId, - }: { - integrationWhatsapp: IntegrationWhatsappModel - ownerId: string - workspaceId: string - }, -): Promise { - await tx - .update(coexistSyncRunModel) - .set({ - status: "failed", - finishedAt: new Date(), - currentError: "Integration disconnected", - }) - .where( - and( - eq(coexistSyncRunModel.integrationId, integrationWhatsapp.id), - inArray(coexistSyncRunModel.status, LIVE_RUN_STATUSES), - ), - ) - - 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, - workspaceId, - reason: "manual", - tx, - }) -} - export const disconnectWhatsappAction = workspaceActionClientAllowExpired .bindArgsSchemas(workspaceIdAndIdRequestParams) .action( @@ -126,10 +47,11 @@ export const disconnectWhatsappAction = workspaceActionClientAllowExpired } await db.transaction((tx) => - purgeWhatsappIntegration(tx, { + integrationWhatsappService.disconnect({ integrationWhatsapp, ownerId: workspace.ownerId, workspaceId, + tx, }), ) diff --git a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts index 40f23c0e7e..8fb728c4b6 100644 --- a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts @@ -1,5 +1,4 @@ -import { findOrFail } from "@chatbotx.io/database/client" -import { integrationWhatsappModel } from "@chatbotx.io/database/schema" +import { integrationWhatsappService } from "@chatbotx.io/business" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" import { type ConversationalAutomation, @@ -13,14 +12,14 @@ export const findWhatsappAutomation = async ( ): Promise => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId: input.workspaceId, id: input.id, - }, - message: "Whatsapp integration not found", - }) + }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } return await findConversationalAutomation( integrationWhatsapp.auth as WhatsappAuthValue, 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..b2f5a03c0a 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,12 @@ "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" + buildContext, + integrationWhatsappService, + whatsappFlowService, +} from "@chatbotx.io/business" 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 +17,11 @@ export const syncWhatsappFlowsAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { - workspaceId, - id, - }, - message: "Whatsapp integration not found", - }) + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -41,59 +37,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 whatsappFlowService.syncFromMeta({ + integrationWhatsappId: integrationWhatsapp.id, + flows: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/flows/api/private.ts b/apps/builder/src/features/integration-whatsapp/flows/api/private.ts index c9fd859a78..b4d381f53b 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/api/private.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/api/private.ts @@ -1,14 +1,12 @@ -import { buildContext } from "@chatbotx.io/business" -import { findOrFail } from "@chatbotx.io/database/client" import { - integrationWhatsappModel, - whatsappFlowModel, -} from "@chatbotx.io/database/schema" + buildContext, + integrationWhatsappService, + whatsappFlowService, +} from "@chatbotx.io/business" import { type WhatsappAuthValue, integration as whatsappIntegration, } from "@chatbotx.io/integration-whatsapp" -import { whatsappFlowService } from "@/features/integration-whatsapp/flows/queries" import { workspaceAuthorizedMidddleware } from "@/middlewares/auth" import { authorizedAPI } from "@/orpc" import { @@ -44,20 +42,16 @@ export const whatsappFlowInternalAPIs = { .use(workspaceAuthorizedMidddleware, (input) => input.workspaceId) .output(getWhatsappFlowScreensResponse) .handler(async ({ input }) => { - const flow = await findOrFail({ - table: whatsappFlowModel, - where: { id: input.flowId }, - message: "Whatsapp flow not found", - }) + const flow = await whatsappFlowService.findByIdUnscoped(input.flowId) - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ id: flow.integrationWhatsappId, workspaceId: input.workspaceId, - }, - message: "Whatsapp integration not found", - }) + }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId: input.workspaceId, diff --git a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts index fc3af2128e..eb12acf8e8 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts @@ -1,31 +1 @@ -import { type DatabaseClient, db } from "@chatbotx.io/database/client" -import type { ListWhatsappFlowsResponse } from "@/features/integration-whatsapp/flows/schema/query" - -export const whatsappFlowService = { - list: (props: { - tx?: DatabaseClient - where: { - workspaceId: string - inboxId?: string - integrationWhatsappId?: string - } - }): Promise => { - const { tx = db, where } = props - - const queryWhere = { - integrationWhatsappId: where.integrationWhatsappId, - integrationWhatsapp: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - } - - return tx.query.whatsappFlowModel.findMany({ - where: queryWhere, - with: { - integrationWhatsapp: true, - }, - orderBy: { createdAt: "asc" }, - }) - }, -} +export { whatsappFlowService } from "@chatbotx.io/business" 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..301c2f22cc 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,12 @@ "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" + buildContext, + integrationWhatsappService, + whatsappMessageTemplateService, +} from "@chatbotx.io/business" 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 +17,11 @@ export const syncMessageTemplateAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { - workspaceId, - id, - }, - message: "Whatsapp integration not found", - }) + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -39,65 +35,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 whatsappMessageTemplateService.syncFromMeta({ + integrationWhatsappId: integrationWhatsapp.id, + templates: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts b/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts index 38f8467fc2..8d08d9de9f 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts @@ -1,33 +1 @@ -import { type DatabaseClient, db } from "@chatbotx.io/database/client" -import type { WhatsappTemplateStatus } from "@chatbotx.io/database/partials" -import type { ListWhatsappMessageTemplatesResponse } from "@/features/integration-whatsapp/message-templates/schema/query" - -export const whatsappMessageTemplateService = { - list: (props: { - tx?: DatabaseClient - where: { - workspaceId: string - inboxId?: string - integrationWhatsappId?: string - status?: WhatsappTemplateStatus - } - }): Promise => { - const { tx = db, where } = props - - const queryWhere = { - integrationWhatsappId: where.integrationWhatsappId, - integrationWhatsapp: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - } - - return tx.query.whatsappMessageTemplateModel.findMany({ - where: queryWhere, - with: { - integrationWhatsapp: true, - }, - orderBy: { createdAt: "asc" }, - }) - }, -} +export { whatsappMessageTemplateService } from "@chatbotx.io/business" diff --git a/apps/builder/src/features/integration-whatsapp/queries/index.ts b/apps/builder/src/features/integration-whatsapp/queries/index.ts index 35c331e41c..75ec95d19b 100644 --- a/apps/builder/src/features/integration-whatsapp/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/queries/index.ts @@ -1,5 +1,8 @@ -import type { IntegrationWhatsappResource } from "@chatbotx.io/business" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { + type IntegrationWhatsappResource, + integrationWhatsappService, +} from "@chatbotx.io/business" +import { db, findOrFail } from "@chatbotx.io/database/client" import { integrationWhatsappModel } from "@chatbotx.io/database/schema" import type { InboxModel, @@ -109,8 +112,5 @@ export const markWhatsappWebhookVerified = async ( }, } - await db - .update(integrationWhatsappModel) - .set({ auth: updatedAuth }) - .where(eq(integrationWhatsappModel.id, id)) + await integrationWhatsappService.markWebhookVerified(id, updatedAuth) } diff --git a/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts b/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts index 595c7b1955..0cc05fc297 100644 --- a/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts +++ b/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts @@ -37,39 +37,17 @@ vi.mock("@/features/workspace-members/queries", () => ({ })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/client -// Zalo action does NOT call .returning(), so the chainable builder only needs -// update → set → where. +// Mock @chatbotx.io/database/client — findOrFail is still reached by the +// workspaceActionClient auth chain, even though the action itself no longer +// calls `db` directly. // --------------------------------------------------------------------------- -const dbUpdateBuilder = { - set: vi.fn(), - where: vi.fn(), -} - vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: vi.fn(), - }, findOrFail: vi.fn(), isDatabaseError: vi.fn(() => false), - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, -})) - -// --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/schema -// --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationZaloModel: { - id: "id", - workspaceId: "workspaceId", - syncTagEnabledAt: "syncTagEnabledAt", - }, - userModel: { id: "id" }, })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/business (isPlatformAdmin) and errors +// Mock @chatbotx.io/business (isPlatformAdmin, zaloIntegrationService) and errors // // This factory mock enumerates exports, so it must cover everything // `workspaceActionClient` reaches — not just what this action calls directly. @@ -78,6 +56,8 @@ vi.mock("@chatbotx.io/database/schema", () => ({ // unrelated failure. `isWorkspaceScheduledForDeletion` is the deletion gate in // `lib/safe-action.ts`; `false` = an active workspace, this action's precondition. // --------------------------------------------------------------------------- +const updateTagSync = vi.fn() + vi.mock("@chatbotx.io/business", () => ({ isPlatformAdmin: vi.fn(async () => false), isWorkspaceScheduledForDeletion: vi.fn(() => false), @@ -91,6 +71,7 @@ vi.mock("@chatbotx.io/business", () => ({ isSupportSession: false, } }), + zaloIntegrationService: { updateTagSync }, })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -123,7 +104,7 @@ vi.mock("@/lib/log", () => ({ // --------------------------------------------------------------------------- const { toggleZaloTagSyncAction } = await import("../toggle-tag-sync.action") const { invalidateCacheByTags } = await import("@chatbotx.io/redis") -const { db, findOrFail } = await import("@chatbotx.io/database/client") +const { findOrFail } = await import("@chatbotx.io/database/client") const { getCurrentUserId } = await import("@/lib/auth/utils") const { getAllWorkspaceMembers } = await import( "@/features/workspace-members/queries" @@ -132,7 +113,6 @@ const { getAllWorkspaceMembers } = await import( const invalidateCacheByTagsMock = invalidateCacheByTags as ReturnType< typeof vi.fn > -const dbUpdate = db.update as ReturnType const findOrFailMock = findOrFail as ReturnType const getCurrentUserIdMock = getCurrentUserId as ReturnType const getAllWorkspaceMembersMock = getAllWorkspaceMembers as ReturnType< @@ -165,36 +145,21 @@ describe("toggleZaloTagSyncAction", () => { workspaceIds: [WORKSPACE_ID], }) - // Re-wire the chainable DB builder (Zalo action: update().set().where()) - dbUpdateBuilder.set.mockReturnValue(dbUpdateBuilder) - // where() must resolve to a promise since the action awaits the chain - dbUpdateBuilder.where.mockResolvedValue(undefined) - dbUpdate.mockReturnValue(dbUpdateBuilder) + updateTagSync.mockResolvedValue(undefined) }) // ── enabled: true ────────────────────────────────────────────────────────── describe("enabled: true", () => { - test("sets syncTagEnabledAt to a Date instance (not null)", async () => { - await invokeAction(true) - - expect(dbUpdate).toHaveBeenCalledTimes(1) - - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeInstanceOf(Date) - expect(setArg.syncTagEnabledAt).not.toBeNull() - }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { + test("calls zaloIntegrationService.updateTagSync with a truthy enabled flag", async () => { await invokeAction(true) - expect(dbUpdateBuilder.where).toHaveBeenCalledTimes(1) - // and() mock returns [...args], so the array has two eq() calls - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) + expect(updateTagSync).toHaveBeenCalledTimes(1) + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: true, + }) }) test("calls invalidateCacheByTags with the workspace-scoped zalo key", async () => { @@ -210,13 +175,14 @@ describe("toggleZaloTagSyncAction", () => { // ── enabled: false ───────────────────────────────────────────────────────── describe("enabled: false", () => { - test("sets syncTagEnabledAt to null", async () => { + test("calls zaloIntegrationService.updateTagSync with a falsy enabled flag", async () => { await invokeAction(false) - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeNull() + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: false, + }) }) test("calls invalidateCacheByTags with the workspace-scoped zalo key", async () => { @@ -226,25 +192,15 @@ describe("toggleZaloTagSyncAction", () => { `workspaces:${WORKSPACE_ID}#zalos`, ]) }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - await invokeAction(false) - - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) }) // ── no matching row (no-op) ──────────────────────────────────────────────── - // Zalo action does NOT use .returning() — it returns void. - // When no row matches, the update is a no-op at DB level; the action still - // completes without throwing. + // The service's update is a no-op at DB level when no row matches; the + // action still completes without throwing. describe("no matching row (no-op)", () => { test("returns void (undefined data) without throwing", async () => { - // where() resolves to undefined (no rows affected) — action returns void - dbUpdateBuilder.where.mockResolvedValue(undefined) + updateTagSync.mockResolvedValue(undefined) const result = await invokeAction(true) @@ -253,7 +209,7 @@ describe("toggleZaloTagSyncAction", () => { }) test("still calls invalidateCacheByTags even when no row was updated", async () => { - dbUpdateBuilder.where.mockResolvedValue(undefined) + updateTagSync.mockResolvedValue(undefined) await invokeAction(false) diff --git a/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts b/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts index 5b409839d6..b2cfd1be95 100644 --- a/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts @@ -1,7 +1,7 @@ import { - connectChannelIntegration, tagSyncService, workspaceService, + zaloIntegrationService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" @@ -10,7 +10,6 @@ import { channelTypes, type ZaloCredential, } from "@chatbotx.io/database/partials" -import { integrationZaloModel } from "@chatbotx.io/database/schema" import type { ZaloAuthValue } from "@chatbotx.io/integration-zalo" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { redirect } from "next/navigation" @@ -46,37 +45,21 @@ export async function connectZaloHandler({ let connectedIntegrationId: string | undefined let channelWasCreated = false + let wasDuplicate = false try { await db.transaction(async (tx) => { - const { wasCreated } = await connectChannelIntegration({ - tx, - ownerId, - inboxData: { + const { integrationId, wasCreated } = + await zaloIntegrationService.connect({ + tx, + ownerId, workspaceId, - name: authValue.metadata.oaName, - channel: "zalo", - sourceId: authValue.oaId, - }, - insertIntegration: async (inboxId, insertWasCreated) => { - if (!insertWasCreated) { - redirect( - `/space/${workspaceId}/settings/channels?channel=zalo&error=duplicated`, - ) - } - const [row] = await tx - .insert(integrationZaloModel) - .values({ - inboxId, - workspaceId, - oaId: authValue.oaId, - auth: authValue, - name: authValue.metadata.oaName, - }) - .returning({ id: integrationZaloModel.id }) - connectedIntegrationId = row?.id - }, - }) + oaId: authValue.oaId, + oaName: authValue.metadata.oaName, + auth: authValue, + }) + connectedIntegrationId = integrationId channelWasCreated = wasCreated + wasDuplicate = !integrationId }) } catch (error) { if ( @@ -90,6 +73,12 @@ export async function connectZaloHandler({ throw error } + if (wasDuplicate) { + redirect( + `/space/${workspaceId}/settings/channels?channel=zalo&error=duplicated`, + ) + } + if (channelWasCreated) { await auditService.record({ userId, diff --git a/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts b/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts index 6eab945d64..6afc571014 100644 --- a/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts @@ -1,13 +1,12 @@ "use server" -import { inboxService, 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 { - integrationZaloModel, - tagChannelModel, -} from "@chatbotx.io/database/schema" + inboxService, + workspaceService, + zaloIntegrationService, +} from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" +import { db } from "@chatbotx.io/database/client" import { isRevokedTokenError, type ZaloAuthValue, @@ -24,14 +23,7 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired bindArgsParsedInputs: [workspaceId, id], } = props const [integrationZalo, workspace] = await Promise.all([ - findOrFail({ - table: integrationZaloModel, - where: { - workspaceId, - id, - }, - message: "Integration Zalo OA not found", - }), + zaloIntegrationService.findById({ id, workspaceId }), workspaceService.findById({ id: workspaceId }), ]) @@ -49,18 +41,7 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired } await db.transaction(async (tx) => { - // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId - await tx - .delete(tagChannelModel) - .where( - and( - eq(tagChannelModel.channelType, channelTypes.enum.zalo), - eq(tagChannelModel.integrationId, integrationZalo.id), - ), - ) - await tx - .delete(integrationZaloModel) - .where(eq(integrationZaloModel.id, integrationZalo.id)) + await zaloIntegrationService.disconnect({ id: integrationZalo.id, tx }) await inboxService.disconnect({ inboxId: integrationZalo.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts b/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts index 7af10723a2..0e03eebd31 100644 --- a/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts @@ -1,7 +1,6 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { integrationZaloModel } from "@chatbotx.io/database/schema" +import { zaloIntegrationService } from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -16,15 +15,11 @@ export const toggleZaloTagSyncAction = workspaceActionClient parsedInput: { enabled }, } = props - await db - .update(integrationZaloModel) - .set({ syncTagEnabledAt: enabled ? new Date() : null }) - .where( - and( - eq(integrationZaloModel.id, integrationId), - eq(integrationZaloModel.workspaceId, workspaceId), - ), - ) + await zaloIntegrationService.updateTagSync({ + workspaceId, + integrationId, + enabled, + }) await invalidateCacheByTags([`workspaces:${workspaceId}#zalos`]) }) diff --git a/apps/builder/src/features/integration-zalo/queries/index.ts b/apps/builder/src/features/integration-zalo/queries/index.ts index 06ce8e7f81..8d3271fd5c 100644 --- a/apps/builder/src/features/integration-zalo/queries/index.ts +++ b/apps/builder/src/features/integration-zalo/queries/index.ts @@ -1,5 +1,7 @@ -import type { IntegrationZaloResource } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" +import { + type IntegrationZaloResource, + zaloIntegrationService, +} from "@chatbotx.io/business" import type { IntegrationZaloModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -10,13 +12,7 @@ export const findIntegrationZalo = async ({ }): Promise => { await assertCurrentUserCanAccessChatbot(workspaceId) - return ( - (await db.query.integrationZaloModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null - ) + return (await zaloIntegrationService.findByWorkspaceId(workspaceId)) ?? null } export const listIntegrationZalo = async ({ @@ -24,12 +20,7 @@ export const listIntegrationZalo = async ({ }: { where: Partial> }): Promise<{ data: IntegrationZaloModel[] }> => { - const data = await db.query.integrationZaloModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await zaloIntegrationService.listByWorkspaceId(where) return { data } } diff --git a/apps/builder/src/features/integrations/api/public.ts b/apps/builder/src/features/integrations/api/public.ts index e5de7c2ddb..ef370a13d2 100644 --- a/apps/builder/src/features/integrations/api/public.ts +++ b/apps/builder/src/features/integrations/api/public.ts @@ -1,30 +1,7 @@ -import { integrationService } from "@chatbotx.io/business" -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" -import { - paginateInMemory, - publicListRequest, - publicListResponse, -} from "@/lib/public-api/list" -import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { publicIntegrationResource } from "../schema/resource" - -const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") +import { integrationsAiPublicRouter } from "./public/ai" +import { integrationsCrudPublicRouter } from "./public/crud" export const integrationsPublicRouter = { - list: workspaceTokenAuthAPI - .route({ - method: "GET", - path: "/v1/integrations", - summary: "List integrations", - tags: ["Integrations"], - }) - .input(publicListRequest) - .output(publicListResponse(publicIntegrationResource)) - .errors(possibleErrorsOnListingResource) - .handler(async ({ context, input }) => { - const data = await integrationService.listByWorkspaceId( - context.workspace.id, - ) - return paginateInMemory(data, input) - }), + ...integrationsCrudPublicRouter, + ...integrationsAiPublicRouter, } diff --git a/apps/builder/src/features/integrations/api/public/ai.ts b/apps/builder/src/features/integrations/api/public/ai.ts new file mode 100644 index 0000000000..9b630b4433 --- /dev/null +++ b/apps/builder/src/features/integrations/api/public/ai.ts @@ -0,0 +1,146 @@ +import { aiProviders } from "@chatbotx.io/ai" +import { aiIntegrationService } from "@chatbotx.io/ai/server" +import { + integrationClaudeService, + integrationDeepSeekService, + integrationGeminiService, + integrationOpenAIService, +} from "@chatbotx.io/business" +import { + notFoundException, + validationException, +} from "@chatbotx.io/business/errors" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" +import { + possibleErrorsOnFindingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + type AiProviderPathParam, + connectAiProviderRequest, + getAiProviderRequest, + publicAiProviderResource, +} from "../../schema/ai-provider" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") + +type AiProviderRow = { + id: string + model: string + temperature: number | null + maxOutputTokens: number + autoReply: boolean + auth: unknown +} + +const toResource = (row: AiProviderRow) => ({ + id: row.id, + model: row.model, + temperature: row.temperature, + maxOutputTokens: row.maxOutputTokens, + autoReply: row.autoReply, + hasApiKey: Boolean(row.auth), +}) + +const aiProviderServices = { + claude: integrationClaudeService, + deepseek: integrationDeepSeekService, + gemini: integrationGeminiService, + openai: integrationOpenAIService, +} satisfies Record< + AiProviderPathParam, + { + findByWorkspaceId: ( + workspaceId: string, + ) => Promise + connect: (input: { + workspaceId: string + apiKey: string + model: string + temperature: number + maxOutputTokens: number + }) => Promise + disconnect: (workspaceId: string) => Promise + } +> + +export const integrationsAiPublicRouter = { + getAiProvider: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/ai/{provider}", + summary: "Get an AI provider integration", + tags: ["Integrations"], + }) + .input(getAiProviderRequest) + .output(publicAiProviderResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + const row = await service.findByWorkspaceId(context.workspace.id) + if (!row) { + throw notFoundException(`${input.provider} integration not found`) + } + return toResource(row) + }), + + connectAiProvider: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/integrations/ai/{provider}", + summary: "Connect or update an AI provider integration", + description: + "Upserts the AI provider integration for the workspace — connects it if not already configured, otherwise replaces the stored configuration (including the API key).", + tags: ["Integrations"], + }) + .input(getAiProviderRequest.extend(connectAiProviderRequest.shape)) + .output(publicAiProviderResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + + if (!(await verifyAiProviderApiKey(input.provider, input.apiKey))) { + throw validationException("apiKey", "Invalid API key") + } + + await service.connect({ + workspaceId: context.workspace.id, + apiKey: input.apiKey, + model: input.model, + temperature: input.temperature, + maxOutputTokens: input.maxOutputTokens, + }) + + await aiIntegrationService.invalidateCache( + context.workspace.id, + aiProviders.enum[input.provider], + ) + + const row = await service.findByWorkspaceId(context.workspace.id) + if (!row) { + throw notFoundException(`${input.provider} integration not found`) + } + return toResource(row) + }), + + disconnectAiProvider: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/integrations/ai/{provider}", + summary: "Disconnect an AI provider integration", + tags: ["Integrations"], + successStatus: 204, + }) + .input(getAiProviderRequest) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + await service.disconnect(context.workspace.id) + + await aiIntegrationService.invalidateCache( + context.workspace.id, + aiProviders.enum[input.provider], + ) + }), +} diff --git a/apps/builder/src/features/integrations/api/public/crud.ts b/apps/builder/src/features/integrations/api/public/crud.ts new file mode 100644 index 0000000000..390f49d634 --- /dev/null +++ b/apps/builder/src/features/integrations/api/public/crud.ts @@ -0,0 +1,77 @@ +import { integrationService } from "@chatbotx.io/business" +import { notFoundException } from "@chatbotx.io/business/errors" +import { + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, +} from "@/lib/orpc/orpc-error-helper" +import { + paginateInMemory, + publicListRequest, + publicListResponse, +} from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + getIntegrationRequest, + listTokenRefreshErrorsResponse, +} from "../../schema/public" +import { publicIntegrationResource } from "../../schema/resource" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") + +export const integrationsCrudPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations", + summary: "List integrations", + tags: ["Integrations"], + }) + .input(publicListRequest) + .output(publicListResponse(publicIntegrationResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const data = await integrationService.listByWorkspaceId( + context.workspace.id, + ) + return paginateInMemory(data, input) + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/{id}", + summary: "Get an integration", + tags: ["Integrations"], + }) + .input(getIntegrationRequest) + .output(publicIntegrationResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const integration = await integrationService.findByIdForWorkspace({ + id: input.id, + workspaceId: context.workspace.id, + }) + if (!integration) { + throw notFoundException("Integration not found") + } + return integration + }), + + tokenErrors: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/status/token-errors", + summary: "List channel integrations with a failed token refresh", + description: + "Channel integrations whose daily automatic token-refresh last failed — a signal the channel needs a manual reconnect before it silently stops sending or receiving messages.", + tags: ["Integrations"], + }) + .output(listTokenRefreshErrorsResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context }) => { + const data = await integrationService.findTokenRefreshErrorsByWorkspaceId( + context.workspace.id, + ) + return { data } + }), +} diff --git a/apps/builder/src/features/integrations/queries/get-ai-integrations.ts b/apps/builder/src/features/integrations/queries/get-ai-integrations.ts index 41e02fff4c..3a8d87038f 100644 --- a/apps/builder/src/features/integrations/queries/get-ai-integrations.ts +++ b/apps/builder/src/features/integrations/queries/get-ai-integrations.ts @@ -1,5 +1,5 @@ import { aiProviders } from "@chatbotx.io/ai" -import { db } from "@chatbotx.io/database/client" +import { integrationService } from "@chatbotx.io/business" type ListAIIntegrationsProps = { where: { @@ -8,25 +8,15 @@ type ListAIIntegrationsProps = { } export async function listAIIntegrations(props: ListAIIntegrationsProps) { - return await db.query.integrationModel.findMany({ - where: { - integrationType: { - in: [...aiProviders.options], - }, - workspaceId: props.where.workspaceId, - }, + return await integrationService.listByWorkspaceIdAndTypes({ + workspaceId: props.where.workspaceId, + integrationTypes: [...aiProviders.options], }) } export async function hasAIIntegration(workspaceId: string): Promise { - const exists = await db.query.integrationModel.findFirst({ - where: { - integrationType: { - in: [...aiProviders.options], - }, - workspaceId, - }, + return await integrationService.existsByWorkspaceIdAndTypes({ + workspaceId, + integrationTypes: [...aiProviders.options], }) - - return !!exists } diff --git a/apps/builder/src/features/integrations/schema/ai-provider.ts b/apps/builder/src/features/integrations/schema/ai-provider.ts new file mode 100644 index 0000000000..611a232d5a --- /dev/null +++ b/apps/builder/src/features/integrations/schema/ai-provider.ts @@ -0,0 +1,32 @@ +import { z } from "zod" + +export const aiProviderPathParam = z.enum([ + "claude", + "deepseek", + "gemini", + "openai", +]) +export type AiProviderPathParam = z.infer + +export const getAiProviderRequest = z.object({ + provider: aiProviderPathParam, +}) + +// Never includes `auth` — the encrypted/secret credential. Only `hasApiKey` +// (a boolean) signals whether a key is configured; the raw secret must never +// reach a public API response. +export const publicAiProviderResource = z.object({ + id: z.string(), + model: z.string(), + temperature: z.number().nullable(), + maxOutputTokens: z.number(), + autoReply: z.boolean(), + hasApiKey: z.boolean(), +}) + +export const connectAiProviderRequest = z.object({ + apiKey: z.string().min(1), + model: z.string().min(1), + temperature: z.coerce.number().min(0).max(2), + maxOutputTokens: z.coerce.number().int().min(1).max(8192), +}) diff --git a/apps/builder/src/features/integrations/schema/public.ts b/apps/builder/src/features/integrations/schema/public.ts new file mode 100644 index 0000000000..92becba97f --- /dev/null +++ b/apps/builder/src/features/integrations/schema/public.ts @@ -0,0 +1,25 @@ +import { z } from "zod" + +export const getIntegrationRequest = z.object({ + id: z.string(), +}) + +export const tokenRefreshErrorChannel = z.enum([ + "zalo", + "tiktok", + "instagram", + "instagramFacebook", + "messenger", + "whatsapp", +]) + +export const tokenRefreshErrorResource = z.object({ + id: z.string(), + channel: tokenRefreshErrorChannel, + name: z.string(), + error: z.string(), +}) + +export const listTokenRefreshErrorsResponse = z.object({ + data: z.array(tokenRefreshErrorResource), +}) diff --git a/apps/worker/__tests__/bulk-historical-import.test.ts b/apps/worker/__tests__/bulk-historical-import.test.ts index a1fce00c5e..f9b6133116 100644 --- a/apps/worker/__tests__/bulk-historical-import.test.ts +++ b/apps/worker/__tests__/bulk-historical-import.test.ts @@ -1,26 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // --------------------------------------------------------------------------- -// Hoist mocks — the bulk pipeline calls many DB primitives we must stub: -// transaction() runs the callback inline against a fake `tx` object that -// exposes select(), insert(), update(), delete(), and execute(). +// Hoist mocks. The whole `bulkImportContacts` transaction (select existing +// ContactInbox rows, resolve/heal Conversations, insert Contact+ContactInbox+ +// Conversation, race recovery, scoped-user-id aliasing) moved VERBATIM into +// `coexistImportService.resolveOrCreateContactLinks` — the worker layer no +// longer touches `db`/`tx` at all for that phase. `bulkImportMessages` still +// inserts via `createMessageRepository().bulkCreate()` (unchanged) but now +// enriches the Contact row via `contactRepository.enrichIfNull` instead of a +// raw `tx.execute(sql...)`. // --------------------------------------------------------------------------- const { - mockTransaction, - mockTxSelect, - mockTxInsert, - mockTxUpdate, - mockTxDelete, - mockTxExecute, - mockDbUpdate, mockEmitContactCreated, mockEmit, - mockCreateId, mockBulkCreate, mockBulkUpdateTracking, mockCreateMessageRepository, - mockWorkspaceFind, + mockResolveOrCreateContactLinks, + mockEnrichIfNull, mockWorkspaceUsageIncrement, mockBulkAdvanceActivityAndAiContextMarker, } = vi.hoisted(() => { @@ -31,20 +29,13 @@ const { bulkCreateAttachments: mockBulkCreateAttachments, }) return { - mockTransaction: vi.fn(), - mockTxSelect: vi.fn(), - mockTxInsert: vi.fn(), - mockTxUpdate: vi.fn(), - mockTxDelete: vi.fn(), - mockTxExecute: vi.fn(), - mockDbUpdate: vi.fn(), mockEmitContactCreated: vi.fn(() => Promise.resolve()), mockEmit: vi.fn(() => Promise.resolve()), - mockCreateId: vi.fn(), mockBulkCreate, mockBulkUpdateTracking: vi.fn().mockResolvedValue(null), mockCreateMessageRepository, - mockWorkspaceFind: vi.fn(), + mockResolveOrCreateContactLinks: vi.fn(), + mockEnrichIfNull: vi.fn().mockResolvedValue(undefined), mockWorkspaceUsageIncrement: vi.fn().mockResolvedValue(undefined), mockBulkAdvanceActivityAndAiContextMarker: vi .fn() @@ -52,71 +43,21 @@ const { } }) -vi.mock("@chatbotx.io/database/client", () => { - const tx = { - select: mockTxSelect, - insert: mockTxInsert, - update: mockTxUpdate, - delete: mockTxDelete, - execute: mockTxExecute, - } - mockTransaction.mockImplementation((cb: (tx: unknown) => unknown) => cb(tx)) - // db.update() is used best-effort outside transactions (lastMessageAt bump). - // Return a chainable stub so it never throws. - mockDbUpdate.mockImplementation(() => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - return chain - }) - return { - db: { - execute: mockTxExecute, - transaction: mockTransaction, - update: mockDbUpdate, - }, - and: vi.fn((...args: unknown[]) => ({ __and: args })), - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), - inArray: vi.fn((col: unknown, vals: unknown) => ({ - __inArray: [col, vals], - })), - or: vi.fn((...args: unknown[]) => ({ __or: args })), - sql: Object.assign( - (strings: TemplateStringsArray, ..._args: unknown[]) => ({ - __sql: strings.raw, - }), - { - raw: (s: string) => s, - join: (chunks: unknown[], _sep?: unknown) => ({ __join: chunks }), - }, - ), - } -}) - -vi.mock("@chatbotx.io/database/schema", () => ({ - attachmentModel: { id: "att_id" }, - contactInboxModel: { - id: "ci_id", - sourceId: "ci_sourceId", - sourceUserId: "ci_sourceUserId", - contactId: "ci_contactId", - inboxId: "ci_inboxId", - }, - contactModel: { id: "c_id" }, - conversationModel: { id: "conv_id", contactId: "conv_contactId" }, - messageModel: { - id: "m_id", - contactInboxId: "m_ci", - sourceId: "m_sid", - $inferInsert: {}, - }, +vi.mock("@chatbotx.io/database/client", () => ({ + describeDatabaseError: vi.fn((err: unknown) => err), })) vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactRepository: { + enrichIfNull: mockEnrichIfNull, + }, })) vi.mock("@chatbotx.io/business", () => ({ + coexistImportService: { + resolveOrCreateContactLinks: mockResolveOrCreateContactLinks, + }, contactInboxService: { bulkUpdateTracking: mockBulkUpdateTracking, }, @@ -124,12 +65,6 @@ vi.mock("@chatbotx.io/business", () => ({ bulkAdvanceActivityAndAiContextMarker: mockBulkAdvanceActivityAndAiContextMarker, }, - messageCleanupService: { - cancelByInboxSource: vi.fn().mockResolvedValue(undefined), - }, - workspaceService: { - find: mockWorkspaceFind, - }, workspaceUsageService: { increment: mockWorkspaceUsageIncrement, }, @@ -139,12 +74,6 @@ vi.mock("@chatbotx.io/event-bus", () => ({ emit: mockEmit })) vi.mock("@chatbotx.io/events", () => ({ emitContactCreated: mockEmitContactCreated, })) -// Partial: `@chatbotx.io/database/partials` calls `zodBigintAsString()` at -// module scope, so replacing the whole module breaks the import chain. -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: mockCreateId } -}) // --------------------------------------------------------------------------- // Import after mocks @@ -152,93 +81,6 @@ vi.mock("@chatbotx.io/utils", async (importOriginal) => { import { bulkImportHistorical } from "../src/integration/handlers/coexist/bulk-historical-import" -// --------------------------------------------------------------------------- -// Helpers — chain builders mirroring Drizzle's fluent API -// --------------------------------------------------------------------------- - -type SelectStubConfig = { - /** Rows returned by .where() for SELECTs without .limit(), or by .limit() otherwise. */ - rows?: unknown[] - /** Set to true when production code chains .limit(n) after .where(). */ - hasLimit?: boolean -} - -/** Stub a single tx.select(...).from(...).where(...)[.limit()] chain returning `rows`. */ -const enqueueSelect = (config: SelectStubConfig = {}) => { - const chain = { - from: vi.fn(), - where: vi.fn(), - limit: vi.fn(), - } - chain.from.mockReturnValue(chain) - if (config.hasLimit) { - // .where() returns the chain for further chaining; .limit() resolves - chain.where.mockReturnValue(chain) - chain.limit.mockResolvedValue(config.rows ?? []) - } else { - chain.where.mockResolvedValue(config.rows ?? []) - } - mockTxSelect.mockReturnValueOnce(chain) - return chain -} - -type InsertStubConfig = { - returningRows?: unknown[] -} - -/** - * Stub tx.insert(...).values(...) which may be terminal, or chain - * .onConflictDoNothing().returning() / .onConflictDoUpdate() / .returning(). - */ -const enqueueInsert = (config: InsertStubConfig = {}) => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn(), - onConflictDoUpdate: vi.fn(), - returning: vi.fn(), - } - chain.values.mockReturnValue(chain) - chain.onConflictDoNothing.mockReturnValue(chain) - chain.onConflictDoUpdate.mockResolvedValue(undefined) - chain.returning.mockResolvedValue(config.returningRows ?? []) - mockTxInsert.mockReturnValueOnce(chain) - return chain -} - -/** - * Stub tx.insert(...).values(...).onConflictDoNothing(...) where the chain is - * AWAITED directly without .returning() (e.g. the Conversation insert). - */ -const enqueueInsertNoReturning = () => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn(), - returning: vi.fn(), - } - chain.values.mockReturnValue(chain) - chain.onConflictDoNothing.mockResolvedValue(undefined) - chain.returning.mockResolvedValue([]) - mockTxInsert.mockReturnValueOnce(chain) - return chain -} - -/** Stub tx.update(...).set(...).where(...) — awaitable at .where(). */ -const _enqueueUpdate = () => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - mockTxUpdate.mockReturnValueOnce(chain) - return chain -} - -/** Stub tx.delete(...).where(...). */ -const _enqueueDelete = () => { - const chain = { where: vi.fn() } - chain.where.mockResolvedValue(undefined) - mockTxDelete.mockReturnValueOnce(chain) - return chain -} - const inbox = { id: "inbox-1", workspaceId: "ws-1", @@ -266,13 +108,14 @@ const msg = (sourceId: string, overrides: Record = {}) => ({ }) // --------------------------------------------------------------------------- -// Helpers — stub a "new contacts" happy path inside bulkImportContacts tx. -// Sequence (when trulyNew > 0, no race): -// 1. SELECT existing ContactInbox rows → enqueueSelect({ rows: [] }) -// 2. INSERT Contact (terminal, no .returning()) → enqueueInsert() -// 3. INSERT ContactInbox .returning() → enqueueInsert({ returningRows }) -// 4. INSERT Conversation .onConflictDoNothing() → enqueueInsertNoReturning() -// 5. SELECT conversations for new contacts → enqueueSelect({ rows }) +// Helpers — wire `coexistImportService.resolveOrCreateContactLinks`, which now +// owns the whole contact-resolution transaction (select existing rows, heal +// orphan conversations, insert Contact/ContactInbox/Conversation, race +// recovery, scoped-user-id aliasing) VERBATIM — see +// packages/business/src/coexist-import/service.ts. The worker-layer test only +// asserts `bulkImportContacts`/`bulkImportHistorical` consume this result +// correctly; the transaction internals are covered at the business-service +// layer, not here. // --------------------------------------------------------------------------- type NewContactStub = { @@ -282,27 +125,58 @@ type NewContactStub = { conversationId: string } -const stubNewContactsTransaction = (contacts: NewContactStub[]) => { - // 1. SELECT existing ContactInbox → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: contacts.map((c) => ({ id: c.contactId })) }) - // 3. INSERT ContactInbox .returning() - enqueueInsert({ - returningRows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, +/** "New contacts" happy path — every entry is newly created. */ +const stubNewContactsResolution = (contacts: NewContactStub[]) => { + mockResolveOrCreateContactLinks.mockResolvedValueOnce({ + importedContacts: contacts.length, + contactInboxIds: new Map( + contacts.map((c) => [ + c.sourceId, + { + contactInboxId: c.contactInboxId, + contactId: c.contactId, + conversationId: c.conversationId, + }, + ]), + ), + newContactCreatedEvents: contacts.map((c) => ({ + workspaceId, contactId: c.contactId, + contactInboxId: c.contactInboxId, + sourceId: c.sourceId, + firstName: "Bob", + phoneNumber: undefined, + email: "bob@example.com", + channel: inbox.channel, + source: "inboundMessage", + createdAt: new Date(), })), }) - // 4. INSERT Conversation .onConflictDoNothing() - enqueueInsertNoReturning() - // 5. SELECT conversations for new contacts - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), +} + +type ExistingContactStub = { + sourceId: string + contactId: string + contactInboxId: string + conversationId: string +} + +/** "Already exists" path — every entry resolves to a pre-existing row, no new + * contact created and no `newContactCreatedEvents` emitted. */ +const stubExistingContactsResolution = (contacts: ExistingContactStub[]) => { + mockResolveOrCreateContactLinks.mockResolvedValueOnce({ + importedContacts: 0, + contactInboxIds: new Map( + contacts.map((c) => [ + c.sourceId, + { + contactInboxId: c.contactInboxId, + contactId: c.contactId, + conversationId: c.conversationId, + }, + ]), + ), + newContactCreatedEvents: [], }) } @@ -313,24 +187,6 @@ const stubNewContactsTransaction = (contacts: NewContactStub[]) => { describe("bulkImportHistorical", () => { beforeEach(() => { vi.clearAllMocks() - let idCounter = 0 - mockCreateId.mockImplementation(() => `id-${++idCounter}`) - // Re-wire transaction default after clearAllMocks resets it. - const tx = { - select: mockTxSelect, - insert: mockTxInsert, - update: mockTxUpdate, - delete: mockTxDelete, - execute: mockTxExecute, - } - mockTransaction.mockImplementation((cb: (tx: unknown) => unknown) => cb(tx)) - // Re-wire db.update default after clearAllMocks. - mockDbUpdate.mockImplementation(() => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - return chain - }) // Re-wire repository mock after clearAllMocks. mockBulkCreate.mockResolvedValue([]) mockBulkUpdateTracking.mockResolvedValue(null) @@ -339,14 +195,11 @@ describe("bulkImportHistorical", () => { bulkCreate: mockBulkCreate, bulkCreateAttachments: vi.fn().mockResolvedValue([]), }) - mockWorkspaceFind.mockResolvedValue({ - id: "workspace-1", - ownerId: "owner-1", - }) + mockEnrichIfNull.mockResolvedValue(undefined) mockWorkspaceUsageIncrement.mockResolvedValue(undefined) }) - it("empty batch returns zero counts without opening a transaction", async () => { + it("empty batch returns zero counts without calling resolveOrCreateContactLinks", async () => { const result = await bulkImportHistorical({ inbox, workspaceId, @@ -365,11 +218,11 @@ describe("bulkImportHistorical", () => { insertedAttachmentIds: [], failureReason: undefined, }) - expect(mockTransaction).not.toHaveBeenCalled() + expect(mockResolveOrCreateContactLinks).not.toHaveBeenCalled() }) it("inserts new contact + messages when no existing ContactInbox matches", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -406,7 +259,7 @@ describe("bulkImportHistorical", () => { }) it("tracks workspace usage for newly-imported coexist contacts without consuming quota", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -424,7 +277,6 @@ describe("bulkImportHistorical", () => { batch: [{ contact: contact("src-1"), messages: [msg("m-src-1")] }], }) - expect(mockWorkspaceFind).not.toHaveBeenCalled() expect(mockWorkspaceUsageIncrement).toHaveBeenCalledWith( workspaceId, "contacts", @@ -433,15 +285,17 @@ describe("bulkImportHistorical", () => { }) it("does not touch the contacts quota when no new contact is imported", async () => { - // All contacts already exist — bulkImportContacts resolves them without - // any new insert, so importedContacts stays 0 (mirrors the idempotent - // re-run scenario below). - enqueueSelect({ - rows: [{ id: "ci-existing", sourceId: "src-1", contactId: "c-existing" }], - }) - enqueueSelect({ - rows: [{ id: "conv-existing", contactId: "c-existing" }], - }) + // All contacts already exist — resolveOrCreateContactLinks resolves them + // without any new insert, so importedContacts stays 0 (mirrors the + // idempotent re-run scenario below). + stubExistingContactsResolution([ + { + sourceId: "src-1", + contactId: "c-existing", + contactInboxId: "ci-existing", + conversationId: "conv-existing", + }, + ]) mockBulkCreate.mockResolvedValueOnce([]) const result = await bulkImportHistorical({ @@ -453,14 +307,13 @@ describe("bulkImportHistorical", () => { }) expect(result.importedContacts).toBe(0) - expect(mockWorkspaceFind).not.toHaveBeenCalled() expect(mockWorkspaceUsageIncrement).not.toHaveBeenCalled() }) it("flushes contact-inbox activity in one bulk service call", async () => { const firstMessageAt = new Date("2026-07-01T01:00:00.000Z") const secondMessageAt = new Date("2026-07-02T02:00:00.000Z") - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -517,7 +370,7 @@ describe("bulkImportHistorical", () => { }) it("advances the AI marker by default (aiReadsSyncedHistory: false) so the AI ignores synced history", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -559,7 +412,7 @@ describe("bulkImportHistorical", () => { }) it("leaves the marker untouched (null) for every row when aiReadsSyncedHistory is true, so the AI reads synced history", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -599,7 +452,7 @@ describe("bulkImportHistorical", () => { }) it("counts duplicates as skippedMessages when message INSERT returns fewer rows than input", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -628,14 +481,16 @@ describe("bulkImportHistorical", () => { }) it("uses existing ContactInbox row for already-known sourceId (idempotent re-run)", async () => { - // existing row present - enqueueSelect({ - rows: [{ id: "ci-existing", sourceId: "src-1", contactId: "c-existing" }], - }) - // conversations lookup for existing contact ids - enqueueSelect({ - rows: [{ id: "conv-existing", contactId: "c-existing" }], - }) + // existing row present — resolveOrCreateContactLinks resolves it without + // any new insert. + stubExistingContactsResolution([ + { + sourceId: "src-1", + contactId: "c-existing", + contactInboxId: "ci-existing", + conversationId: "conv-existing", + }, + ]) // No new contacts → skips cap check, contact insert, etc. // Goes straight to repository.bulkCreate() for messages. mockBulkCreate.mockResolvedValueOnce([]) @@ -658,7 +513,7 @@ describe("bulkImportHistorical", () => { }) it("dedups batch entries that share the same sourceId (merges messages)", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-shared", contactId: "id-1", @@ -692,13 +547,16 @@ describe("bulkImportHistorical", () => { // ------------------------------------------------------------------------- it("H7: racedSourceIds lookup produces correct results with many contacts (Set semantics)", async () => { - // Build a scenario where ALL inserted ContactInbox rows "lose the race" - // (the production code considers sourceIds not in insertedSourceIds as - // raced). We do this by making insertedInboxes return an EMPTY array for - // ContactInbox INSERT — so every sourceId is in racedSourceIds — then - // stub the winner re-SELECT to return the real rows. + // This exercised the O(n²)→O(n) raced-contact-resolution internals of + // `bulkImportContacts`'s transaction, which moved VERBATIM into + // `coexistImportService.resolveOrCreateContactLinks` + // (packages/business/src/coexist-import/service.ts) — the race-recovery + // logic itself is covered there, not at this worker-layer boundary. Here + // we only assert the worker correctly consumes a large resolved-links map + // (e.g. from an all-raced resolution) end to end into per-contact message + // imports. // - // With N = 50 contacts this exercises the O(n) path without being slow. + // With N = 50 contacts this exercises the large-batch path without being slow. const N = 50 const contacts = Array.from({ length: N }, (_, i) => ({ sourceId: `src-${i}`, @@ -707,30 +565,8 @@ describe("bulkImportHistorical", () => { conversationId: `conv-${i}`, })) - // 1. SELECT existing ContactInbox → none (all are new) - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: contacts.map((c) => ({ id: c.contactId })) }) - // 3. INSERT ContactInbox — returns EMPTY → ALL sourceIds go to racedSourceIds - enqueueInsert({ returningRows: [] }) - // 4. Race-winner re-SELECT returns all contacts as winners - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, - contactId: c.contactId, - })), - }) - // 5. DELETE orphan contacts (racedSourceIds.length > 0) - _enqueueDelete() - // 6. INSERT Conversation — skipped because all raced (conversationsToInsert = []) - // 7. SELECT conversations for accepted contacts (via inArray on acceptedContactIds) - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), - }) + // All raced → resolved via winner re-SELECT internally, trulyNew = 0. + stubExistingContactsResolution(contacts) // Each contact's bulkImportMessages call goes through repository.bulkCreate() for (let i = 0; i < N; i++) { @@ -768,20 +604,18 @@ describe("bulkImportHistorical", () => { // A username-adopter thread keyed by its BSUID, whose BSUID already // belongs to a phone-keyed row in this inbox. Inserting would violate // the partial unique index (inboxId, sourceUserId) and abort the batch; - // the entry must resolve to the existing row up front. - // 1. SELECT existing (by sourceId OR sourceUserId) → phone-keyed row - enqueueSelect({ - rows: [ - { - id: "ci-old", - sourceId: "phone-1", - sourceUserId: "user.abc", - contactId: "c-old", - }, - ], - }) - // 2. SELECT conversations for existing contacts - enqueueSelect({ rows: [{ id: "conv-old", contactId: "c-old" }] }) + // the entry must resolve to the existing row up front. This resolution + // itself is now owned by `coexistImportService.resolveOrCreateContactLinks` + // — the worker layer only consumes the resolved link, keyed by the + // entry's own sourceUserId-based import key ("user.abc"), same as before. + stubExistingContactsResolution([ + { + sourceId: "user.abc", + contactId: "c-old", + contactInboxId: "ci-old", + conversationId: "conv-old", + }, + ]) mockBulkCreate.mockResolvedValueOnce([{ id: "m-1", sourceId: "m-src-1" }]) const result = await bulkImportHistorical({ @@ -799,83 +633,22 @@ describe("bulkImportHistorical", () => { expect(result.importedContacts).toBe(0) expect(result.importedMessages).toBe(1) expect(result.contactInboxIds.get("user.abc")).toBe("ci-old") - // No Contact/ContactInbox insert was attempted — the conflict never fires. - expect(mockTxInsert).not.toHaveBeenCalled() + // No new-contact insert was attempted — resolveOrCreateContactLinks + // reports zero imported contacts (the conflict never fires). + expect(mockResolveOrCreateContactLinks).toHaveBeenCalledOnce() }) - it("ContactInbox insert uses targetless onConflictDoNothing (covers both identity indexes)", async () => { - // 1. SELECT existing → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: [{ id: "id-1" }] }) - // 3. INSERT ContactInbox .returning() — the chain under test - const contactInboxInsert = enqueueInsert({ - returningRows: [{ id: "ci-1", sourceId: "src-1", contactId: "id-1" }], - }) - // 4. INSERT Conversation - enqueueInsertNoReturning() - // 5. SELECT conversations - enqueueSelect({ rows: [{ id: "conv-1", contactId: "id-1" }] }) - - await bulkImportHistorical({ - inbox, - workspaceId, - runId: "12345", - batch: [{ contact: contact("src-1"), messages: [] }], - }) - - // A target on (inboxId, sourceId) would let a conflict on the partial - // (inboxId, sourceUserId) index abort the whole batch — pin targetless. - expect(contactInboxInsert.onConflictDoNothing).toHaveBeenCalledWith() - }) - - it("raced scoped-id entry resolves through the winner row and aliases the import key", async () => { - // Concurrent import claimed the BSUID between the resolution SELECT and - // the insert: the insert returns no row, the winner is found by scoped - // user id under a DIFFERENT sourceId, and the entry's own import key - // must still map to the winner's link. - // 1. SELECT existing → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: [{ id: "id-1" }] }) - // 3. INSERT ContactInbox → EMPTY: the row lost the race - enqueueInsert({ returningRows: [] }) - // 4. Winner re-SELECT by sourceId → none (loser conflicted on sourceUserId) - enqueueSelect({ rows: [] }) - // 5. Winner re-SELECT by scoped user id → phone-keyed winner row - enqueueSelect({ - rows: [ - { - id: "ci-w", - sourceId: "phone-9", - sourceUserId: "user.abc", - contactId: "c-w", - }, - ], - }) - // 6. DELETE orphan pre-created contacts - _enqueueDelete() - // 7. Conversation insert skipped (all raced); SELECT conversations - enqueueSelect({ rows: [{ id: "conv-w", contactId: "c-w" }] }) - mockBulkCreate.mockResolvedValueOnce([{ id: "m-1", sourceId: "m-src-1" }]) - - const result = await bulkImportHistorical({ - inbox, - workspaceId, - runId: "12345", - batch: [ - { - contact: contact("user.abc", { sourceUserId: "user.abc" }), - messages: [msg("m-src-1")], - }, - ], - }) - - expect(result.importedContacts).toBe(0) - expect(result.importedMessages).toBe(1) - // The entry's import key aliases to the winner's contact-inbox row. - expect(result.contactInboxIds.get("user.abc")).toBe("ci-w") - }) + // NOTE: the targetless-onConflictDoNothing pin (a target on + // (inboxId, sourceId) would let a conflict on the partial + // (inboxId, sourceUserId) index abort the whole batch) and the + // raced-scoped-id winner-aliasing behavior both live entirely inside the + // `resolveOrCreateContactLinks` transaction now — see + // packages/business/src/coexist-import/service.ts, which is the correct + // place to assert those DB-shape invariants going forward. At the worker + // boundary we only assert the resolved link is consumed and threaded + // through message import + emitted events correctly, covered by the + // "resolves an entry by scoped user id" test above and + // "inserts new contact + messages" below. // ------------------------------------------------------------------------- // H4 — bulkImportHistorical parallelizes per-contact bulkImportMessages @@ -911,21 +684,8 @@ describe("bulkImportHistorical", () => { }, ] - // bulkImportContacts: all existing → no cap check needed - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, - contactId: c.contactId, - })), - }) - // Conversation lookup for existing contacts - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), - }) + // bulkImportContacts: all existing → no new-contact insert needed + stubExistingContactsResolution(contacts) // Track concurrency of repository.bulkCreate calls for the message-import phase. // Each bulkImportMessages call invokes bulkCreate once (after messages are built). diff --git a/apps/worker/__tests__/coexist-instagram-sync.test.ts b/apps/worker/__tests__/coexist-instagram-sync.test.ts index 27ce07be6f..c1b663088a 100644 --- a/apps/worker/__tests__/coexist-instagram-sync.test.ts +++ b/apps/worker/__tests__/coexist-instagram-sync.test.ts @@ -44,7 +44,7 @@ const { vi.mock("@chatbotx.io/business", () => ({ coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findIntegrationForCoexist: mockFindIntegration, findResumeCeiling: mockFindResumeCeiling, findRunById: mockFindRunById, diff --git a/apps/worker/__tests__/coexist-messenger-sync.test.ts b/apps/worker/__tests__/coexist-messenger-sync.test.ts index 9c71bc84e1..7daf4790d5 100644 --- a/apps/worker/__tests__/coexist-messenger-sync.test.ts +++ b/apps/worker/__tests__/coexist-messenger-sync.test.ts @@ -5,9 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // --------------------------------------------------------------------------- const { - mockFindFirstMessenger, - mockFindFirstWorkspace, - mockFindFirstCoexistRun, + mockFindByIdMessenger, + mockFindByIdWorkspace, + mockFindWorkspace, + mockFindLastSyncedAt, + mockFindInitState, + mockFindResumeCeiling, + mockClaimRunForSync, + mockUpdateProgress, + mockIncrementProgress, + mockFindTerminalCounters, + mockMarkFailed, + mockListContactLinksBySourceIds, mockFindOrFail, mockListConversations, mockListMessages, @@ -15,15 +24,22 @@ const { mockBulkImportMessages, mockBulkImportContacts, mockCreateIdFactory, - mockSelect, - mockUpdate, mockQueueAdd, mockConcurrencyForUsage, mockApplyCoexistActivityUpdates, } = vi.hoisted(() => ({ - mockFindFirstMessenger: vi.fn(), - mockFindFirstWorkspace: vi.fn(), - mockFindFirstCoexistRun: vi.fn(), + mockFindByIdMessenger: vi.fn(), + mockFindByIdWorkspace: vi.fn(), + mockFindWorkspace: vi.fn(), + mockFindLastSyncedAt: vi.fn(), + mockFindInitState: vi.fn(), + mockFindResumeCeiling: vi.fn(), + mockClaimRunForSync: vi.fn(), + mockUpdateProgress: vi.fn(), + mockIncrementProgress: vi.fn(), + mockFindTerminalCounters: vi.fn(), + mockMarkFailed: vi.fn(), + mockListContactLinksBySourceIds: vi.fn(), mockFindOrFail: vi.fn(), mockListConversations: vi.fn(), mockListMessages: vi.fn(), @@ -31,8 +47,6 @@ const { mockBulkImportMessages: vi.fn(), mockBulkImportContacts: vi.fn(), mockCreateIdFactory: vi.fn(), - mockSelect: vi.fn(), - mockUpdate: vi.fn(), mockQueueAdd: vi.fn(), mockConcurrencyForUsage: vi.fn(() => 5), mockApplyCoexistActivityUpdates: vi.fn().mockResolvedValue(undefined), @@ -42,34 +56,42 @@ const { // Mocks // --------------------------------------------------------------------------- +// `findOrFail` is still imported from `@chatbotx.io/database/client` directly +// by messenger-sync.ts for the Inbox lookup — keep this mock minimal (plain +// stub, no schema importOriginal) per the hard rule against opening a real DB +// connection. vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: mockUpdate, - select: mockSelect, - query: { - integrationMessengerModel: { findFirst: mockFindFirstMessenger }, - integrationWhatsappModel: { findFirst: vi.fn() }, - workspaceModel: { findFirst: mockFindFirstWorkspace }, - coexistSyncRunModel: { findFirst: mockFindFirstCoexistRun }, - }, - }, - and: vi.fn(), - eq: vi.fn(), - inArray: vi.fn(), - isNull: vi.fn(), - lt: vi.fn(), - ne: vi.fn(), - or: vi.fn(), - sql: Object.assign( - (strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings, - values, - }), - { raw: (s: string) => s }, - ), findOrFail: mockFindOrFail, })) +vi.mock("@chatbotx.io/business", () => ({ + extractContactInfo: vi.fn(() => ({})), + messengerIntegrationService: { + findById: mockFindByIdMessenger, + }, + workspaceService: { + findById: mockFindByIdWorkspace, + find: mockFindWorkspace, + }, + coexistService: { + findLastSyncedAt: mockFindLastSyncedAt, + findInitState: mockFindInitState, + findResumeCeiling: mockFindResumeCeiling, + reclaimRunForRetry: mockClaimRunForSync, + updateProgress: mockUpdateProgress, + incrementProgress: mockIncrementProgress, + findTerminalCounters: mockFindTerminalCounters, + markFailed: mockMarkFailed, + }, + coexistImportService: { + listContactLinksBySourceIds: mockListContactLinksBySourceIds, + }, +})) + +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("@chatbotx.io/worker-config", () => ({ IntegrationJobAction: { coexistWhatsappBuffer: "coexistWhatsappBuffer", @@ -85,33 +107,7 @@ vi.mock("@chatbotx.io/worker-config", () => ({ })) vi.mock("@chatbotx.io/database/schema", () => ({ - whatsappCoexistStagingModel: {}, - integrationWhatsappModel: {}, - integrationMessengerModel: {}, inboxModel: {}, - contactInboxModel: { - id: "id", - sourceId: "sourceId", - contactId: "contactId", - inboxId: "inboxId", - }, - conversationModel: { id: "id", contactId: "contactId" }, - coexistSyncRunModel: { - id: "id", - lastSyncedAt: "lastSyncedAt", - attempts: "attempts", - importedContactCount: "importedContactCount", - importedMessageCount: "importedMessageCount", - skippedCount: "skippedCount", - failedCount: "failedCount", - currentScan: "currentScan", - currentError: "currentError", - messengerSyncPhase: "messengerSyncPhase", - lastHeartbeatAt: "lastHeartbeatAt", - currentStep: "currentStep", - startedAt: "startedAt", - status: "status", - }, })) vi.mock("@chatbotx.io/integration-messenger/apis/sync", () => ({ @@ -145,12 +141,6 @@ vi.mock("../src/integration/handlers/coexist/bulk-historical-import", () => ({ }, })) -// Break the pino import chain that comes through @chatbotx.io/business → -// @chatbotx.io/redis → @chatbotx.io/logger → pino. -vi.mock("@chatbotx.io/business", () => ({ - extractContactInfo: vi.fn(() => ({})), -})) - // --------------------------------------------------------------------------- // Import handler after mocks // --------------------------------------------------------------------------- @@ -257,12 +247,12 @@ const defaultContactLink = { // --------------------------------------------------------------------------- /** - * Chainable select stub. The chain: - * - `.from().leftJoin().where()` → resolves to `contactLinks` (JOIN path) - * - `.from().where().limit(1)` → resolves to `[runRow]` (run row path) - * - * Both paths reuse the same chain so `mockSelect.mockReturnValue(chain)` works - * for all `db.select()` callsites in a single test. + * Wires the two service calls the source uses in place of the old raw + * `db.select()` JOIN/limit chain: + * - `coexistService.findLastSyncedAt` → the run-row-existence + watermark + * check that each phase does at its start (`{ lastSyncedAt } | null`). + * - `coexistImportService.listContactLinksBySourceIds` → the + * ContactInbox+Conversation JOIN, keyed by sourceId. */ const wireSelectChain = ( runRow: ReturnType | null, @@ -273,52 +263,22 @@ const wireSelectChain = ( conversationId: string }> = [], ) => { - const limitFn = vi.fn().mockResolvedValue(runRow ? [runRow] : []) - // A thenable that resolves to contactLinks AND has .limit() for run-row queries. - const whereResult = Object.assign(Promise.resolve(contactLinks), { - limit: limitFn, - }) - - const chain = { - from: vi.fn(), - leftJoin: vi.fn(), - where: vi.fn(), - limit: vi.fn().mockResolvedValue(runRow ? [runRow] : []), - } - chain.from.mockReturnValue(chain) - chain.leftJoin.mockReturnValue(chain) - chain.where.mockReturnValue(whereResult) - mockSelect.mockReturnValue(chain) - return chain + mockFindLastSyncedAt.mockResolvedValue( + runRow ? { lastSyncedAt: runRow.lastSyncedAt } : null, + ) + mockListContactLinksBySourceIds.mockResolvedValue(contactLinks) } /** - * Reusable update chain — every db.update() returns a fresh chain. Supports - * both the "fire-and-forget" pattern (`await db.update().set().where()`) AND - * the optimistic-claim pattern (`await db.update().set().where().returning()`). - * `.where()` returns a real Promise (resolves to undefined for the - * fire-and-forget path) with `.returning()` attached for the claim path — - * using a real Promise avoids the `noThenProperty` lint while staying - * awaitable. The default claim result is `[{ id: runId }]` so the handler - * treats the run as successfully claimed; tests that need "already claimed" - * pass `wireUpdateChain([])`. + * Wires `coexistService.reclaimRunForRetry` — the optimistic claim that replaces + * the old raw `db.update().set().where().returning()` chain. Default claim + * result is the run row (handler treats the run as successfully claimed); + * tests that need "already claimed" pass `wireUpdateChain(null)`. */ const wireUpdateChain = ( - claimResult: Array<{ id: string }> = [{ id: runId }], + claimResult: { id: string } | null = { id: runId }, ) => { - mockUpdate.mockImplementation(() => { - const chain = { - set: vi.fn(), - where: vi.fn(), - } - chain.set.mockReturnValue(chain) - chain.where.mockImplementation(() => - Object.assign(Promise.resolve(undefined), { - returning: vi.fn().mockResolvedValue(claimResult), - }), - ) - return chain - }) + mockClaimRunForSync.mockResolvedValue(claimResult) } // --------------------------------------------------------------------------- @@ -342,13 +302,27 @@ describe("coexistMessengerSync", () => { mockBulkImportContacts.mockResolvedValue(emptyBulkContactsResult()) mockCreateIdFactory.mockReturnValue(() => "id-factory-result") mockQueueAdd.mockResolvedValue(undefined) - mockFindFirstWorkspace.mockResolvedValue({ targetCountry: "VN" }) - // Default: first run for this integration (no prior CoexistSyncRun). - mockFindFirstCoexistRun.mockResolvedValue(null) + mockFindWorkspace.mockResolvedValue({ targetCountry: "VN" }) + mockFindInitState.mockResolvedValue({ + attempts: 0, + currentError: null, + messengerSyncPhase: "messages", + }) + mockUpdateProgress.mockResolvedValue(undefined) + mockIncrementProgress.mockResolvedValue(undefined) + mockMarkFailed.mockResolvedValue(undefined) + mockFindTerminalCounters.mockResolvedValue({ + importedMessageCount: 0, + skippedCount: 0, + failedCount: 0, + }) + // Default: no ceiling — first run for this integration (no prior + // succeeded/partial CoexistSyncRun). + mockFindResumeCeiling.mockResolvedValue(null) }) it("is a no-op when integration is not found", async () => { - mockFindFirstMessenger.mockResolvedValue(null) + mockFindByIdMessenger.mockResolvedValue(null) await coexistMessengerSync({ runId, integrationId, workspaceId }) @@ -357,7 +331,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when workspaceId mismatches the row", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, workspaceId: "other-ws", }) @@ -369,7 +343,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when coexistEnabled === false", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, coexistEnabled: false, }) @@ -381,7 +355,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when access token is missing", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, auth: { tokens: {}, metadata: {} }, }) @@ -392,7 +366,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when CoexistSyncRun row is gone", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) wireSelectChain(null) @@ -403,7 +377,7 @@ describe("coexistMessengerSync", () => { }) it("fetches one page of conversations and invokes bulkImportMessages with assembled messages", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -444,7 +418,7 @@ describe("coexistMessengerSync", () => { }) it("advances the AI marker by default (coexistAiReadsSyncedHistory off), carrying the newest message id", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -478,7 +452,7 @@ describe("coexistMessengerSync", () => { }) it("passes a null aiMarkerMessageId when coexistAiReadsSyncedHistory is on (AI reads synced history)", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, coexistAiReadsSyncedHistory: true, }) @@ -515,7 +489,7 @@ describe("coexistMessengerSync", () => { }) it("paginates messages within a conversation — flushes bulkImportMessages per message page", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -554,7 +528,7 @@ describe("coexistMessengerSync", () => { }) it("skips conversations that contain only the Page PSID", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // No contact links since there are no valid participants wireSelectChain(defaultRunRow(), []) @@ -577,7 +551,7 @@ describe("coexistMessengerSync", () => { }) it("never uses the Page PSID as a contact sourceId", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const customerId = "user-customer-789" @@ -605,7 +579,7 @@ describe("coexistMessengerSync", () => { }) it("skips messages without a text body", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -637,7 +611,7 @@ describe("coexistMessengerSync", () => { }) it("does not synthesize system time when a Messenger message has no API created_time", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -669,7 +643,7 @@ describe("coexistMessengerSync", () => { }) it("persists lastSyncedAt watermark (oldest CONVERSATION.updated_time processed) after the page", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Two convs with distinct updated_time. Watermark must track the oldest @@ -712,31 +686,28 @@ describe("coexistMessengerSync", () => { await coexistMessengerSync({ runId, integrationId, workspaceId }) - const allSetCalls = mockUpdate.mock.results - .flatMap((r) => { - const value = r.value as { set?: ReturnType } | undefined - return value?.set?.mock.calls ?? [] - }) - .map((args) => args[0] as Record) - - const watermarkCall = allSetCalls.find( - (payload) => - payload && - "lastSyncedAt" in payload && - payload.lastSyncedAt instanceof Date, - ) + // The per-page update carries the watermark via + // `coexistService.incrementProgress({ fields: { lastSyncedAt, ... } })`. + const watermarkCall = mockIncrementProgress.mock.calls + .map((args) => args[0] as { fields?: Record }) + .find( + (payload) => + payload.fields && + "lastSyncedAt" in payload.fields && + payload.fields.lastSyncedAt instanceof Date, + ) expect(watermarkCall).toBeDefined() - expect((watermarkCall?.lastSyncedAt as Date).toISOString()).toBe( + expect((watermarkCall?.fields?.lastSyncedAt as Date).toISOString()).toBe( olderConvTs, ) }) it("aborts when optimistic claim returns empty (run already owned by another worker)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) - // Override the default wire-up so the claim UPDATE returns [] — handler + // Override the default wire-up so the claim returns null — handler // must log + return without calling listConversations. - wireUpdateChain([]) + wireUpdateChain(null) mockListConversations.mockResolvedValueOnce({ data: [makeConversation("conv-never-fetched", "user-1")], @@ -750,7 +721,7 @@ describe("coexistMessengerSync", () => { }) it("skips conversations whose updated_time is newer than the within-run frontier", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Frontier: anything strictly newer than this was processed in a prior @@ -793,15 +764,14 @@ describe("coexistMessengerSync", () => { }) it("stops the walk when prior run succeeded — ceiling = priorRun.startedAt", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const priorStartedAt = new Date(Date.now() - 12 * 60 * 60 * 1000) - mockFindFirstCoexistRun.mockResolvedValue({ - startedAt: priorStartedAt, - lastSyncedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - status: "succeeded", - }) + // coexistService.findResumeCeiling already derives the ceiling from the + // prior run's status (succeeded → startedAt) — the handler just consumes + // the resolved Date. + mockFindResumeCeiling.mockResolvedValue(priorStartedAt) const belowCeilingTs = new Date( priorStartedAt.getTime() - 60 * 1000, @@ -825,15 +795,14 @@ describe("coexistMessengerSync", () => { }) it("after prior partial — ceiling = priorRun.lastSyncedAt (boundary the prior attempt reached)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const priorLastSyncedAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - mockFindFirstCoexistRun.mockResolvedValue({ - startedAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - lastSyncedAt: priorLastSyncedAt, - status: "partial", - }) + // coexistService.findResumeCeiling already derives the ceiling from the + // prior run's status (partial → lastSyncedAt) — the handler just consumes + // the resolved Date. + mockFindResumeCeiling.mockResolvedValue(priorLastSyncedAt) const aboveCeilingTs = new Date( priorLastSyncedAt.getTime() + 60 * 1000, @@ -871,7 +840,7 @@ describe("coexistMessengerSync", () => { }) it("conversation fetch failure counts as one failed contact (no N-message inflation)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Provide a link so the conv is processed (not skipped for missing link). wireSelectChain(defaultRunRow(), [ @@ -891,32 +860,26 @@ describe("coexistMessengerSync", () => { // bulkImportMessages was not called since the conv failed before reaching it. expect(mockBulkImportMessages).not.toHaveBeenCalled() - // failedCount in the per-page update set should be 1 — not 100. - const allSetPayloads = mockUpdate.mock.results - .flatMap((r) => { - const value = r.value as { set?: ReturnType } | undefined - return value?.set?.mock.calls ?? [] - }) - .map((args) => args[0] as Record) - - // Find the per-page update (phase=messages page N processed) that carries - // the failedCount increment — the "done" finalisation update does not - // include failedCount. - const pageUpdateWithFailure = allSetPayloads.find( - (payload) => - payload && - typeof payload.currentStep === "string" && - (payload.currentStep as string).includes("phase=messages") && - (payload.currentStep as string).includes("processed"), - ) - expect(pageUpdateWithFailure).toBeDefined() - // The sql`` tag mock returns { strings, values }. The increment literal is - // the second interpolation: sql`${model.failedCount} + ${pageFailed}` - // → values = [model.failedCount, pageFailed]. Extract the numeric arg. - const sqlObj = pageUpdateWithFailure?.failedCount as - | { values: unknown[] } - | undefined - expect(sqlObj?.values[1]).toBe(1) + // failedCount in the per-page increment call should be 1 — not 100. The + // atomic `sql\`col + N\`` composition now lives inside + // coexistSyncRunRepository.incrementProgress (unit-tested at that layer); + // here we assert the handler passed the equivalent increment value. + const pageIncrementWithFailure = mockIncrementProgress.mock.calls + .map( + (args) => + args[0] as { + increments?: Record + fields?: Record + }, + ) + .find( + (payload) => + typeof payload.fields?.currentStep === "string" && + (payload.fields.currentStep as string).includes("phase=messages") && + (payload.fields.currentStep as string).includes("processed"), + ) + expect(pageIncrementWithFailure).toBeDefined() + expect(pageIncrementWithFailure?.increments?.failedCount).toBe(1) }) // --------------------------------------------------------------------------- @@ -924,7 +887,7 @@ describe("coexistMessengerSync", () => { // --------------------------------------------------------------------------- it("H1 — caps BUC pause at 300 s even when estimatedTimeToRegainAccess = 3600", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // The first call to concurrencyForUsage returns 0 (budget exhausted), then @@ -970,7 +933,7 @@ describe("coexistMessengerSync", () => { // --------------------------------------------------------------------------- it("M3 — bulkImportMessages is called once per message page, not once after all pages", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ diff --git a/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts b/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts index 93e2b4da57..f70aa9ddce 100644 --- a/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts @@ -5,30 +5,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // (vi.mock calls are hoisted to the top of the file by Vitest) // --------------------------------------------------------------------------- -const { mockInsert, mockFindFirst, mockQueueAdd } = vi.hoisted(() => ({ - mockInsert: vi.fn(), - mockFindFirst: vi.fn(), - mockQueueAdd: vi.fn(), -})) +const { mockFindByPhoneNumberId, mockStagePayload, mockQueueAdd } = vi.hoisted( + () => ({ + mockFindByPhoneNumberId: vi.fn(), + mockStagePayload: vi.fn(), + mockQueueAdd: vi.fn(), + }), +) // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockInsert, - update: vi.fn(), - select: vi.fn(), - query: { - integrationWhatsappModel: { findFirst: mockFindFirst }, - integrationMessengerModel: { findFirst: vi.fn() }, - }, +// Plain object stubs — never importOriginal @chatbotx.io/database/schema (it +// opens a real DB connection). This module has no direct model dependency +// left after the refactor, but keep the mock present in case a sibling import +// still resolves through it transitively. +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByPhoneNumberId: mockFindByPhoneNumberId, + }, + whatsappCoexistStagingRepository: { + stagePayload: mockStagePayload, }, - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), - findOrFail: vi.fn(), })) vi.mock("@chatbotx.io/worker-config", () => ({ @@ -44,17 +43,6 @@ vi.mock("@chatbotx.io/worker-config", () => ({ integrationQueue: { add: mockQueueAdd }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - whatsappCoexistStagingModel: { - id: "id", - phoneNumberId: "phoneNumberId", - processedAt: "processedAt", - }, - coexistSyncRunModel: { id: "id" }, - integrationWhatsappModel: {}, - inboxModel: {}, -})) - vi.mock("@chatbotx.io/utils", async (importOriginal) => { const actual = await importOriginal() return { @@ -69,27 +57,6 @@ vi.mock("@chatbotx.io/utils", async (importOriginal) => { import { coexistWhatsappBuffer } from "../src/integration/handlers/coexist/whatsapp-buffer" -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -/** - * Builds a chainable Drizzle insert stub supporting two call patterns: - * 1. staging row: .insert(staging).values(...).onConflictDoNothing() - * 2. run row: .insert(run).values(...).returning([{id:'run-1'}]) - */ -const makeInsertChain = () => { - mockInsert.mockImplementation(() => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn().mockResolvedValue(undefined), - returning: vi.fn().mockResolvedValue([{ id: "run-1" }]), - } - chain.values.mockReturnValue(chain) - return chain - }) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -100,13 +67,13 @@ describe("coexistWhatsappBuffer", () => { beforeEach(() => { vi.clearAllMocks() - makeInsertChain() + mockStagePayload.mockResolvedValue(undefined) }) it("inserts a row into whatsapp_coexist_staging keyed by phoneNumberId with payload preserved", async () => { // Integration must exist — buffer now validates ownership BEFORE insert to // avoid orphaned staging rows from a webhook with an unknown phoneNumberId. - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ phoneNumberId, coexistEnabled: false, inboxId: "inbox-1", @@ -114,14 +81,14 @@ describe("coexistWhatsappBuffer", () => { await coexistWhatsappBuffer({ phoneNumberId, payload }) - expect(mockInsert).toHaveBeenCalledOnce() - expect(mockInsert.mock.results[0]?.value.values).toHaveBeenCalledWith( + expect(mockStagePayload).toHaveBeenCalledOnce() + expect(mockStagePayload).toHaveBeenCalledWith( expect.objectContaining({ phoneNumberId, payload }), ) }) it("enqueues a single coalesced coexistWhatsappFlush when coexistEnabled === true", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -143,7 +110,7 @@ describe("coexistWhatsappBuffer", () => { }) it("does NOT enqueue flush when coexistEnabled === false", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ phoneNumberId, coexistEnabled: false, inboxId: "inbox-1", @@ -155,7 +122,7 @@ describe("coexistWhatsappBuffer", () => { }) it("does NOT enqueue flush when integration is not found", async () => { - mockFindFirst.mockResolvedValue(null) + mockFindByPhoneNumberId.mockResolvedValue(null) await coexistWhatsappBuffer({ phoneNumberId, payload }) @@ -167,7 +134,7 @@ describe("coexistWhatsappBuffer", () => { // coalesced flush would stay dead for every number that had already flushed. // The generation prefix is the rollout fix. it("the coalesced flush id carries the v2 generation prefix", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -191,8 +158,8 @@ describe("coexistWhatsappBuffer", () => { // tail re-check (see coexistWhatsappFlush), not the buffer's job. // ───────────────────────────────────────────────────────────────────────── - it("enqueues only the coalesced flush — no per-webhook follow-up job", async () => { - mockFindFirst.mockResolvedValue({ + it("M2: enqueues only the coalesced flush — no per-webhook follow-up job", async () => { + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -218,7 +185,7 @@ describe("coexistWhatsappBuffer", () => { // ───────────────────────────────────────────────────────────────────────── it("the coalesced flush removes itself on complete AND on fail so the jobId frees up", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -240,7 +207,7 @@ describe("coexistWhatsappBuffer", () => { }) it("keeps the delay so burst webhooks still coalesce into one flush", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, diff --git a/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts index 670ba93a6a..fcef438288 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts @@ -42,7 +42,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, @@ -783,7 +783,7 @@ describe("coexistWhatsappFlush — run lifecycle", () => { }) // ── the chunk chain must hand the run back before queueing the next one ── - // `claimRun` refuses a `running` run whose heartbeat is under 10 minutes + // `claimRunWithNewToken` refuses a `running` run whose heartbeat is under 10 minutes // old. A continuation queued while this worker still held the claim // therefore lost its own claim and abandoned, leaving the chain to the // scheduler's 1-hour stale sweep — one chunk per hour, then `failed`. diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts index ab7f48a2e4..6069bd71bb 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts @@ -81,7 +81,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts b/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts index c1e24797c0..11b196bfd9 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts @@ -171,7 +171,7 @@ export function createFlushHarness(mocks: FlushHarnessMocks) { /** * Wires the production call graph: * 1. `integrationWhatsappRepository.findByPhoneNumberId` (select … limit 1) - * 2. `coexistService.claimRun` → the claimed run row + ownership token + * 2. `coexistService.claimRunWithNewToken` → the claimed run row + ownership token * 3. `whatsappCoexistStagingRepository.listPending` (select … orderBy … limit) * * The staged select returns `stagedRows` on the first batch call and `[]` on diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush.test.ts index 6b401ed079..0f647e1c88 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.test.ts @@ -81,7 +81,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, diff --git a/apps/worker/__tests__/get-data-from-json-step.test.ts b/apps/worker/__tests__/get-data-from-json-step.test.ts index 0f007ea556..8e2a6832e1 100644 --- a/apps/worker/__tests__/get-data-from-json-step.test.ts +++ b/apps/worker/__tests__/get-data-from-json-step.test.ts @@ -22,7 +22,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock("@chatbotx.io/business", () => ({ - customFieldService: { findBy: vi.fn() }, + customFieldService: { + findBy: vi.fn(), + findManyByIds: mocks.customFieldFindMany, + }, contactCustomFieldService: { setValues: mocks.setValues, setValueByKey: mocks.setValueByKey, @@ -39,17 +42,6 @@ vi.mock("@chatbotx.io/business/contact-custom-field", () => ({ createSourceTimezoneResolver: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - customFieldModel: { - findFirst: vi.fn(), - findMany: mocks.customFieldFindMany, - }, - }, - }, -})) - vi.mock("@chatbotx.io/variables", () => ({ contactVariableService: { getAll: vi.fn() }, extractVariables: vi.fn(() => []), diff --git a/apps/worker/__tests__/inbox-labels.test.ts b/apps/worker/__tests__/inbox-labels.test.ts index 9e146a43db..710663fa7e 100644 --- a/apps/worker/__tests__/inbox-labels.test.ts +++ b/apps/worker/__tests__/inbox-labels.test.ts @@ -3,86 +3,67 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // --------------------------------------------------------------------------- // Mutable state holders (controlled per test) // --------------------------------------------------------------------------- -const queryResults = { - integrationMessengerFindFirst: null as unknown, - integrationZaloFindFirst: null as unknown, - tagModelFindFirst: null as unknown, - tagChannelFindFirst: null as unknown, - contactInboxFindMany: [] as unknown[], +const state = { + messengerIntegration: null as unknown, + zaloIntegration: null as unknown, + tagChannel: undefined as { id: string; tagId: string } | undefined, + contactInboxes: [] as { id: string; contactId: string }[], + ensureTagByNameResult: undefined as string | undefined, + ensureTagChannelResult: undefined as string | undefined, + linkTagToContactsReturningNewUnscopedResult: [] as { contactId: string }[], } -const insertReturning = { current: [] as unknown[] } // --------------------------------------------------------------------------- -// DB mock — chainable builder +// Mock: @chatbotx.io/business — tagService, tagSyncService, messenger/zalo +// integration services // --------------------------------------------------------------------------- -function makeChain(): Record { - const builder: Record = {} - const noop = () => builder - builder.values = vi.fn(noop) - builder.onConflictDoNothing = vi.fn(noop) - builder.where = vi.fn(noop) - builder.returning = vi.fn(async () => insertReturning.current) - return builder -} -const insertChain = makeChain() -const deleteChain = makeChain() -;(deleteChain.where as ReturnType).mockImplementation( - async () => undefined, +const linkTagToContactsReturningNewUnscoped = vi.fn( + async () => state.linkTagToContactsReturningNewUnscopedResult, +) +const recordTagChannelAssignmentsUnscoped = vi.fn(async () => undefined) +const deleteTagChannelAssignmentsUnscoped = vi.fn(async () => undefined) +const detachTagFromContactsUnscoped = vi.fn(async () => undefined) +const findTagChannel = vi.fn(async () => state.tagChannel) +const ensureTagByName = vi.fn(async () => state.ensureTagByNameResult) +const ensureTagChannel = vi.fn(async () => state.ensureTagChannelResult) +const enqueueDelete = vi.fn(async () => undefined) +const messengerFindByPageIdUnscoped = vi.fn( + async () => state.messengerIntegration, ) +const zaloFindByOaId = vi.fn(async () => state.zaloIntegration) -// Soft-delete path: db.update(tagModel).set().where().returning() -const updateReturning = { current: [] as unknown[] } -const updateChain: Record = {} -updateChain.set = vi.fn(() => updateChain) -updateChain.where = vi.fn(() => updateChain) -updateChain.returning = vi.fn(async () => updateReturning.current) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - integrationMessengerModel: { - findFirst: vi.fn( - async () => queryResults.integrationMessengerFindFirst, - ), - }, - integrationZaloModel: { - findFirst: vi.fn(async () => queryResults.integrationZaloFindFirst), - }, - tagModel: { - findFirst: vi.fn(async () => queryResults.tagModelFindFirst), - }, - tagChannelModel: { - findFirst: vi.fn(async () => queryResults.tagChannelFindFirst), - }, - contactInboxModel: { - findMany: vi.fn(async () => queryResults.contactInboxFindMany), - }, - }, - insert: vi.fn(() => insertChain), - delete: vi.fn(() => deleteChain), - update: vi.fn(() => updateChain), +vi.mock("@chatbotx.io/business", () => ({ + tagService: { + linkTagToContactsReturningNewUnscoped: (...args: unknown[]) => + linkTagToContactsReturningNewUnscoped(...args), + recordTagChannelAssignmentsUnscoped: (...args: unknown[]) => + recordTagChannelAssignmentsUnscoped(...args), + deleteTagChannelAssignmentsUnscoped: (...args: unknown[]) => + deleteTagChannelAssignmentsUnscoped(...args), + detachTagFromContactsUnscoped: (...args: unknown[]) => + detachTagFromContactsUnscoped(...args), + findTagChannel: (...args: unknown[]) => findTagChannel(...args), + ensureTagByName: (...args: unknown[]) => ensureTagByName(...args), + ensureTagChannel: (...args: unknown[]) => ensureTagChannel(...args), + }, + tagSyncService: { enqueueDelete }, + messengerIntegrationService: { + findByPageIdUnscoped: (...args: unknown[]) => + messengerFindByPageIdUnscoped(...args), + }, + zaloIntegrationService: { + findByOaId: (...args: unknown[]) => zaloFindByOaId(...args), }, - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, - inArray: (...args: unknown[]) => args, - isNull: (...args: unknown[]) => args, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { id: "id", workspaceId: "workspaceId", name: "name" }, - tagChannelModel: { - id: "id", - tagId: "tagId", - channelType: "channelType", - integrationId: "integrationId", - workspaceId: "workspaceId", - externalLabelId: "externalLabelId", - }, - contactsToTagsModel: { contactId: "contactId", tagId: "tagId" }, - contactToTagChannelModel: { - tagId: "tagId", - tagChannelId: "tagChannelId", - contactInboxId: "contactInboxId", +// --------------------------------------------------------------------------- +// Mock: @chatbotx.io/database/repositories +// --------------------------------------------------------------------------- +const listIdsByInboxAndSourceIds = vi.fn(async () => state.contactInboxes) +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactInboxRepository: { + listIdsByInboxAndSourceIds: (...args: unknown[]) => + listIdsByInboxAndSourceIds(...args), }, })) @@ -90,11 +71,6 @@ vi.mock("@chatbotx.io/database/partials", () => ({ channelTypes: { enum: { messenger: "messenger", zalo: "zalo" } }, })) -const enqueueDelete = vi.fn(async () => undefined) -vi.mock("@chatbotx.io/business", () => ({ - tagSyncService: { enqueueDelete }, -})) - const invalidateCacheByTags = vi.fn(async () => undefined) vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags })) @@ -119,23 +95,8 @@ const { handleChannelLabelWebhook } = await import( "../src/integration/handlers/inbox_labels" ) const { logger } = await import("../src/lib/logger") -const { db } = await import("@chatbotx.io/database/client") -const { - tagModel, - tagChannelModel, - contactsToTagsModel, - contactToTagChannelModel, -} = await import("@chatbotx.io/database/schema") - -const dbInsert = db.insert as ReturnType -const dbDelete = db.delete as ReturnType -const dbUpdate = db.update as ReturnType + const loggerWarn = logger.warn as ReturnType -const messengerFindFirst = db.query.integrationMessengerModel - .findFirst as ReturnType -const zaloFindFirst = db.query.integrationZaloModel.findFirst as ReturnType< - typeof vi.fn -> // --------------------------------------------------------------------------- // Fixtures @@ -198,39 +159,28 @@ function zaloData(payload: unknown) { } beforeEach(() => { - queryResults.integrationMessengerFindFirst = null - queryResults.integrationZaloFindFirst = null - queryResults.tagModelFindFirst = null - queryResults.tagChannelFindFirst = null - queryResults.contactInboxFindMany = [] - insertReturning.current = [] - updateReturning.current = [] + state.messengerIntegration = null + state.zaloIntegration = null + state.tagChannel = undefined + state.contactInboxes = [] + state.ensureTagByNameResult = undefined + state.ensureTagChannelResult = undefined + state.linkTagToContactsReturningNewUnscopedResult = [] idCounter = 0 vi.clearAllMocks() - ;(insertChain.values as ReturnType).mockReturnValue(insertChain) - ;( - insertChain.onConflictDoNothing as ReturnType - ).mockReturnValue(insertChain) - ;(insertChain.returning as ReturnType).mockImplementation( - async () => insertReturning.current, - ) - ;(deleteChain.where as ReturnType).mockImplementation( - async () => undefined, + messengerFindByPageIdUnscoped.mockImplementation( + async () => state.messengerIntegration, ) - ;(updateChain.set as ReturnType).mockReturnValue(updateChain) - ;(updateChain.where as ReturnType).mockReturnValue(updateChain) - ;(updateChain.returning as ReturnType).mockImplementation( - async () => updateReturning.current, + zaloFindByOaId.mockImplementation(async () => state.zaloIntegration) + findTagChannel.mockImplementation(async () => state.tagChannel) + listIdsByInboxAndSourceIds.mockImplementation( + async () => state.contactInboxes, ) - dbInsert.mockReturnValue(insertChain) - dbDelete.mockReturnValue(deleteChain) - dbUpdate.mockReturnValue(updateChain) - messengerFindFirst.mockImplementation( - async () => queryResults.integrationMessengerFindFirst, - ) - zaloFindFirst.mockImplementation( - async () => queryResults.integrationZaloFindFirst, + ensureTagByName.mockImplementation(async () => state.ensureTagByNameResult) + ensureTagChannel.mockImplementation(async () => state.ensureTagChannelResult) + linkTagToContactsReturningNewUnscoped.mockImplementation( + async () => state.linkTagToContactsReturningNewUnscopedResult, ) }) @@ -250,11 +200,11 @@ describe("handleChannelLabelWebhook — dispatch", () => { expect.objectContaining({ channel: "telegram" }), "inbox labels: unsupported channel", ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("stops when integration is not found", async () => { - queryResults.integrationMessengerFindFirst = null + state.messengerIntegration = null await handleChannelLabelWebhook( messengerData({ action: "add", @@ -262,12 +212,12 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() expect(loggerWarn).not.toHaveBeenCalled() }) test("stops when tag sync is disabled", async () => { - queryResults.integrationMessengerFindFirst = messengerIntegration({ + state.messengerIntegration = messengerIntegration({ syncTagEnabledAt: null, }) await handleChannelLabelWebhook( @@ -277,11 +227,11 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("warns on invalid payload", async () => { - queryResults.integrationMessengerFindFirst = messengerIntegration() + state.messengerIntegration = messengerIntegration() await handleChannelLabelWebhook({ integrationType: "messenger", integrationIdentifier: PAGE_ID, @@ -299,13 +249,13 @@ describe("handleChannelLabelWebhook — dispatch", () => { // =========================================================================== describe("handleChannelLabelWebhook — messenger", () => { beforeEach(() => { - queryResults.integrationMessengerFindFirst = messengerIntegration() + state.messengerIntegration = messengerIntegration() }) test("add assigns + emits applied when the tag channel already exists", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] - insertReturning.current = [{ contactId: "c-1" }] // newly linked + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] + state.linkTagToContactsReturningNewUnscopedResult = [{ contactId: "c-1" }] // newly linked await handleChannelLabelWebhook( messengerData({ @@ -315,16 +265,24 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1"], + }) + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + tagChannelId: "tc-1", + contactInboxIds: ["ci-1"], + }) expect(emitTagApplied).toHaveBeenCalledWith(WS_ID, "c-1", "tag-1", "ci-1") }) test("add creates tag + channel from page_label_name, then assigns", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null - insertReturning.current = [{ id: "new-id" }] - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] + state.tagChannel = undefined + state.ensureTagByNameResult = "tag-new" + state.ensureTagChannelResult = "tc-new" + state.linkTagToContactsReturningNewUnscopedResult = [{ contactId: "c-1" }] + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] await handleChannelLabelWebhook( messengerData({ @@ -334,15 +292,31 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(tagModel) - expect(dbInsert).toHaveBeenCalledWith(tagChannelModel) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(ensureTagByName).toHaveBeenCalledWith({ + workspaceId: WS_ID, + name: LABEL_NAME, + }) + expect(ensureTagChannel).toHaveBeenCalledWith({ + workspaceId: WS_ID, + tagId: "tag-new", + channelType: "messenger", + integrationId: "intg-msg-1", + externalLabelId: LABEL_ID, + }) + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ + tagId: "tag-new", + contactIds: ["c-1"], + }) + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ + tagId: "tag-new", + tagChannelId: "tc-new", + contactInboxIds: ["ci-1"], + }) }) test("add skips when tag is missing and no name in payload", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null + state.tagChannel = undefined + state.ensureTagByNameResult = undefined await handleChannelLabelWebhook( messengerData({ @@ -352,7 +326,8 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).not.toHaveBeenCalled() + // page_label_name defaults to "" -> ensureTagByName is never a valid create path + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("add without user is a no-op", async () => { @@ -362,12 +337,12 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("remove unassigns: deletes channel mapping + contact tag + emits removed", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] await handleChannelLabelWebhook( messengerData({ @@ -377,8 +352,14 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbDelete).toHaveBeenCalledWith(contactToTagChannelModel) - expect(dbDelete).toHaveBeenCalledWith(contactsToTagsModel) + expect(deleteTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1"], + }) + expect(detachTagFromContactsUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1"], + }) expect(emitTagRemoved).toHaveBeenCalledWith(WS_ID, "c-1", "tag-1", "ci-1") }) @@ -386,7 +367,7 @@ describe("handleChannelLabelWebhook — messenger", () => { await handleChannelLabelWebhook( messengerData({ action: "remove", label: { id: LABEL_ID } }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("unknown action is a no-op", async () => { @@ -397,8 +378,8 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID }, }), ) - expect(dbInsert).not.toHaveBeenCalled() - expect(dbDelete).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) }) @@ -407,15 +388,19 @@ describe("handleChannelLabelWebhook — messenger", () => { // =========================================================================== describe("handleChannelLabelWebhook — zalo", () => { beforeEach(() => { - queryResults.integrationZaloFindFirst = zaloIntegration() + state.zaloIntegration = zaloIntegration() }) test("add_user_to_tag assigns the batch of users", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [ + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [ { id: "ci-1", contactId: "c-1" }, { id: "ci-2", contactId: "c-2" }, ] + state.linkTagToContactsReturningNewUnscopedResult = [ + { contactId: "c-1" }, + { contactId: "c-2" }, + ] await handleChannelLabelWebhook( zaloData({ @@ -425,14 +410,21 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1", "c-2"], + }) + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + tagChannelId: "tc-1", + contactInboxIds: ["ci-1", "ci-2"], + }) }) test("add_user_to_tag with empty user_ids ensures the label only", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null - insertReturning.current = [{ id: "new-id" }] + state.tagChannel = undefined + state.ensureTagByNameResult = "tag-new" + state.ensureTagChannelResult = "tc-new" await handleChannelLabelWebhook( zaloData({ @@ -442,14 +434,23 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(tagModel) - expect(dbInsert).toHaveBeenCalledWith(tagChannelModel) - expect(dbInsert).not.toHaveBeenCalledWith(contactsToTagsModel) + expect(ensureTagByName).toHaveBeenCalledWith({ + workspaceId: WS_ID, + name: LABEL_NAME, + }) + expect(ensureTagChannel).toHaveBeenCalledWith({ + workspaceId: WS_ID, + tagId: "tag-new", + channelType: "zalo", + integrationId: "intg-zalo-1", + externalLabelId: LABEL_NAME, + }) + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("remove_user_from_tag unassigns the batch", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [ + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [ { id: "ci-1", contactId: "c-1" }, { id: "ci-2", contactId: "c-2" }, ] @@ -462,8 +463,14 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbDelete).toHaveBeenCalledWith(contactToTagChannelModel) - expect(dbDelete).toHaveBeenCalledWith(contactsToTagsModel) + expect(deleteTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1", "ci-2"], + }) + expect(detachTagFromContactsUnscoped).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1", "c-2"], + }) expect(emitTagRemoved).toHaveBeenCalledTimes(2) // Each contact attributes to its OWN contactInbox from this label event, // not a shared/most-recent one across the batch. @@ -479,11 +486,11 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME }, }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("remove_user_from_tag is a no-op when the tag channel is missing", async () => { - queryResults.tagChannelFindFirst = null + state.tagChannel = undefined await handleChannelLabelWebhook( zaloData({ event_name: "remove_user_from_tag", @@ -491,11 +498,11 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME, user_ids: ["u-1"] }, }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("remove_tag enqueues a channel-scoped delete + keeps the workspace tag", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } + state.tagChannel = { id: "tc-1", tagId: "tag-1" } await handleChannelLabelWebhook( zaloData({ @@ -512,11 +519,11 @@ describe("handleChannelLabelWebhook — zalo", () => { integrationId: "intg-zalo-1", }) // No workspace-wide tag delete from the webhook. - expect(dbUpdate).not.toHaveBeenCalled() + expect(detachTagFromContactsUnscoped).not.toHaveBeenCalled() }) test("remove_tag is a no-op when the label is not mapped locally", async () => { - queryResults.tagChannelFindFirst = null + state.tagChannel = undefined await handleChannelLabelWebhook( zaloData({ diff --git a/apps/worker/__tests__/integration-worker-incoming-message.test.ts b/apps/worker/__tests__/integration-worker-incoming-message.test.ts index 04aabea7d5..c8759f1e35 100644 --- a/apps/worker/__tests__/integration-worker-incoming-message.test.ts +++ b/apps/worker/__tests__/integration-worker-incoming-message.test.ts @@ -233,6 +233,9 @@ vi.mock("../src/integration/handlers/wait-resume", () => ({ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactInboxRepository: { + findWithContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/automated-response", () => ({ @@ -320,6 +323,9 @@ vi.mock("@chatbotx.io/business", () => ({ conversationService: { findOrCreate: mockConversationFindOrCreate, ensureActive: vi.fn().mockResolvedValue(true), + recordInboundActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), }, workspaceService: { find: vi.fn(), diff --git a/apps/worker/__tests__/read-receipts.test.ts b/apps/worker/__tests__/read-receipts.test.ts index d529537cb4..93530a7bd6 100644 --- a/apps/worker/__tests__/read-receipts.test.ts +++ b/apps/worker/__tests__/read-receipts.test.ts @@ -35,17 +35,12 @@ vi.mock("@chatbotx.io/business", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - }, -})) - vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, getSafeSinceTime: vi.fn((date: Date | null) => date ?? new Date(0)), + contactInboxRepository: { + findWithConversationAndContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/event-bus", () => ({ diff --git a/apps/worker/__tests__/received-message.test.ts b/apps/worker/__tests__/received-message.test.ts index 1015670a68..499e49f5bc 100644 --- a/apps/worker/__tests__/received-message.test.ts +++ b/apps/worker/__tests__/received-message.test.ts @@ -10,8 +10,6 @@ const { mockCreateOrUpdateWithAttachments, mockFindLastByConversation, mockCreateMessageRepository, - mockDbUpdate, - mockFindOrFail, mockFindContactInbox, mockRunChannelHandler, mockBroadcast, @@ -23,15 +21,13 @@ const { mockConversationFindOrCreate, mockAutomatedResponseEnqueueFlowAction, mockIntegrationQueueAdd, - mockDbSet, - mockDbTransaction, - mockDbCount, mockCreateNewContactWithMac, mockWorkspaceFind, mockQuotaIncrement, mockContactUpdate, mockUpdateTracking, mockInvalidateTracking, + mockRecordInboundActivity, mockWorkspaceIsActiveNow, mockAppointmentCancelByToken, mockParseAppointmentCancelPostback, @@ -44,20 +40,7 @@ const { mockRecordProfileRefreshFailure, mockResolveIntegrationContextFromContactInbox, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - const mockDbTransaction = vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ) - const mockDbCount = vi.fn().mockResolvedValue(1) - const mockFindContactInbox = vi.fn() - const mockFindOrFail = vi.fn() const mockRunChannelHandler = vi.fn() @@ -75,9 +58,7 @@ const { mockCreateOrUpdateWithAttachments, mockFindLastByConversation, mockCreateMessageRepository, - mockDbUpdate, mockFindContactInbox, - mockFindOrFail, mockRunChannelHandler, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), @@ -93,9 +74,6 @@ const { .fn() .mockResolvedValue(undefined), mockIntegrationQueueAdd: vi.fn().mockResolvedValue(undefined), - mockDbSet, - mockDbTransaction, - mockDbCount, mockCreateNewContactWithMac: vi.fn(), mockWorkspaceFind: vi.fn().mockResolvedValue(null), mockWorkspaceIsActiveNow: vi.fn().mockReturnValue(true), @@ -104,6 +82,16 @@ const { .fn() .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), + // `conversationService.recordInboundActivity` now owns the transaction + // that used to be `contactInboxService.updateTracking` + a raw + // `db.transaction`/`db.update` on Conversation.lastActivityAt (see + // `persistNewMessageSideEffects`/`recordInboundActivity` in + // packages/business/src/conversation/service.ts). Default resolves a + // tracking invalidation handle so `contactInboxService.invalidateTracking` + // is exercised the same way the real code path does. + mockRecordInboundActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockAppointmentCancelByToken: vi.fn().mockResolvedValue({ cancellable: true, }), @@ -142,6 +130,9 @@ const { vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactInboxRepository: { + findWithContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/automated-response", () => ({ @@ -151,16 +142,6 @@ vi.mock("@chatbotx.io/automated-response", () => ({ })) vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: mockDbUpdate, - query: { - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - $count: mockDbCount, - transaction: mockDbTransaction, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), - findOrFail: mockFindOrFail, isUniqueViolationError: mockIsUniqueViolationError, })) @@ -239,7 +220,10 @@ vi.mock("@chatbotx.io/business", () => ({ unblockIfBlocked: mockContactUnblockIfBlocked, update: mockContactUpdate, }, - conversationService: { findOrCreate: mockConversationFindOrCreate }, + conversationService: { + findOrCreate: mockConversationFindOrCreate, + recordInboundActivity: mockRecordInboundActivity, + }, workspaceService: { find: mockWorkspaceFind, findById: vi.fn().mockResolvedValue({ @@ -537,7 +521,6 @@ describe("receiveMessage — message repository branch", () => { ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -652,21 +635,25 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + // `recordInboundActivity` owns the transaction that used to be a + // standalone `contactInboxService.updateTracking` call plus a raw + // `db.update(conversationModel).set({ lastActivityAt })` — both are now + // internal to the service, so assert the equivalent arguments were + // passed to it (same ids, same tracking values, same activity time). + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastIncomingMessageAt: fakeCreatedMessage.createdAt, lastUserInput: "hello", lastUserInputType: "text", }, - }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: fakeCreatedMessage.createdAt, + contactLocation: null, + at: fakeCreatedMessage.createdAt, }) }) @@ -746,22 +733,21 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) expect(mockContactUnblockIfBlocked).not.toHaveBeenCalled() - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, }, + contactLocation: null, + at: fakeCreatedMessage.createdAt, }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: fakeCreatedMessage.createdAt, - }) - expect(mockUpdateTracking).not.toHaveBeenCalledWith( + expect(mockRecordInboundActivity).not.toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ + tracking: expect.objectContaining({ lastIncomingMessageAt: expect.any(Date), }), }), @@ -829,7 +815,7 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockRecordInboundActivity).not.toHaveBeenCalled() expect(mockUpdateTracking).not.toHaveBeenCalled() }) @@ -943,9 +929,9 @@ describe("receiveMessage — message repository branch", () => { expect(mockCreateOrUpdate).toHaveBeenCalledWith( expect.objectContaining({ text: "Xem sản phẩm" }), ) - expect(mockUpdateTracking).toHaveBeenCalledWith( + expect(mockRecordInboundActivity).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ lastBtnTitle: "Xem sản phẩm" }), + tracking: expect.objectContaining({ lastBtnTitle: "Xem sản phẩm" }), }), ) expect(mockAutomatedResponseEnqueueFlowAction).toHaveBeenCalledWith({ @@ -1127,7 +1113,6 @@ describe("receiveMessage — new contact MAC gate", () => { vi.clearAllMocks() // No existing contact inbox → new-contact creation path. mockFindContactInbox.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -1625,13 +1610,18 @@ describe("receiveMessage — new contact MAC gate", () => { await receiveMessage(baseProps) - expect(mockUpdateTracking).toHaveBeenCalledTimes(1) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + // The location write is now internal to `recordInboundActivity` (see + // `packages/business/src/conversation/service.ts`), which persists the + // tracking fields AND `Contact.location` in one transaction — assert the + // equivalent arguments were passed to it, rather than reaching into the + // service's own internal `contactService.update` call. + expect(mockRecordInboundActivity).toHaveBeenCalledTimes(1) + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-new", contactId: "contact-new", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastIncomingMessageAt: fakeCreatedMessage.createdAt, @@ -1643,12 +1633,9 @@ describe("receiveMessage — new contact MAC gate", () => { }, lastBtnTitle: "Choose plan", }, + contactLocation: { latitude: 10.75, longitude: 106.66 }, + at: fakeCreatedMessage.createdAt, }) - expect(mockContactUpdate).toHaveBeenCalledWith( - { workspaceId: "ws-1", id: "contact-new" }, - { location: { latitude: 10.75, longitude: 106.66 } }, - expect.any(Object), - ) }) test("does not persist location from outgoing channel echoes", async () => { @@ -1686,6 +1673,9 @@ describe("receiveMessage — new contact MAC gate", () => { await receiveMessage(baseProps) expect(mockContactUpdate).not.toHaveBeenCalled() + expect(mockRecordInboundActivity).toHaveBeenCalledWith( + expect.objectContaining({ contactLocation: null }), + ) }) }) @@ -1700,7 +1690,6 @@ describe("receiveMessage — referral-only events", () => { ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -1837,7 +1826,6 @@ describe("receiveMessage — existing contact profile refresh (post-save)", () = ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -2719,7 +2707,6 @@ describe("contact source taxonomy", () => { beforeEach(() => { vi.clearAllMocks() mockFindContactInbox.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -2782,19 +2769,20 @@ describe("contact source taxonomy", () => { const rows = await runCapturedNewContactCreate() expect(rows).toContainEqual(expect.objectContaining({ source: "comments" })) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-new", contactId: "contact-new", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastCommentMessageId: fakeCreatedMessage.id, lastCommentMessageAt: fakeCreatedMessage.createdAt, }, + contactLocation: undefined, + at: fakeCreatedMessage.createdAt, }) - expect(mockDbCount).not.toHaveBeenCalled() expect( vi .mocked(allIntegrations.messenger?.runAction) @@ -2810,7 +2798,6 @@ describe("contact source taxonomy", () => { describe("receiveMessage — BSUID resolver chain (D3)", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( integrationService.identifyInboxAndIntegrationAuthFromIdentifier, @@ -2931,7 +2918,6 @@ describe("receiveMessage — BSUID resolver chain (D3)", () => { describe("receiveMessage — new BSUID-keyed contact creation (D2/D8/§8.1)", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -3140,7 +3126,6 @@ describe("receiveMessage — outbound automated response on message echoes", () ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( diff --git a/apps/worker/__tests__/resolve-workspace-id.test.ts b/apps/worker/__tests__/resolve-workspace-id.test.ts index 536955b40c..21c7b005a0 100644 --- a/apps/worker/__tests__/resolve-workspace-id.test.ts +++ b/apps/worker/__tests__/resolve-workspace-id.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const findBy = vi.fn() -const findFirst = vi.fn() +const importFindWorkspaceId = vi.fn() const identify = vi.fn() const smartDelayFindById = vi.fn() const findWorkspaceId = vi.fn() @@ -9,11 +9,9 @@ const findWorkspaceId = vi.fn() vi.mock("@chatbotx.io/business", () => ({ conversationService: { findBy }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { query: { importModel: { findFirst } } }, -})) vi.mock("@chatbotx.io/database/repositories", () => ({ createAiWorkspaceScopeRepository: () => ({ findWorkspaceId }), + importRepository: { findWorkspaceId: importFindWorkspaceId }, })) vi.mock("@chatbotx.io/business/smart-delay", () => ({ smartDelayService: { findById: smartDelayFindById }, @@ -28,7 +26,7 @@ const { resolveWorkspaceId } = await import("../src/lib/resolve-workspace-id") beforeEach(() => { findBy.mockReset() - findFirst.mockReset() + importFindWorkspaceId.mockReset() identify.mockReset() smartDelayFindById.mockReset() findWorkspaceId.mockReset() @@ -70,7 +68,7 @@ describe("resolveWorkspaceId", () => { }) test("resolves an import id", async () => { - findFirst.mockResolvedValue({ workspaceId: "workspace-from-import" }) + importFindWorkspaceId.mockResolvedValue("workspace-from-import") await expect(resolveWorkspaceId({ importId: "import-1" })).resolves.toBe( "workspace-from-import", ) diff --git a/apps/worker/__tests__/run-ref-minigame-share.test.ts b/apps/worker/__tests__/run-ref-minigame-share.test.ts index c2e88dbc92..79852ada95 100644 --- a/apps/worker/__tests__/run-ref-minigame-share.test.ts +++ b/apps/worker/__tests__/run-ref-minigame-share.test.ts @@ -16,11 +16,22 @@ vi.mock("@chatbotx.io/business/minigame", () => ({ const findOrFail = vi.fn() vi.mock("@chatbotx.io/database/client", () => ({ findOrFail })) -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flowModel.id" }, - flowVersionModel: { id: "flowVersionModel.id" }, - reflinkModel: { id: "reflinkModel.id" }, -})) +// `ref.ts` -> business/minigame -> ...-> contactService pulls in +// `queries/contact-filter`, which touches many unrelated schema tables at +// module scope. A Proxy sentinel satisfies vitest's "does this export +// exist" check for any table name without listing the whole schema. +vi.mock("@chatbotx.io/database/schema", () => { + const overrides = { + flowModel: { id: "flowModel.id" }, + flowVersionModel: { id: "flowVersionModel.id" }, + reflinkModel: { id: "reflinkModel.id" }, + } + return new Proxy(overrides, { + get: (target, prop) => + prop in target ? target[prop as keyof typeof target] : {}, + has: () => true, + }) +}) const emit = vi.fn() vi.mock("@chatbotx.io/event-bus", () => ({ emit })) diff --git a/apps/worker/__tests__/run-ref-referral.test.ts b/apps/worker/__tests__/run-ref-referral.test.ts index 1b677d7707..8ab24db8f7 100644 --- a/apps/worker/__tests__/run-ref-referral.test.ts +++ b/apps/worker/__tests__/run-ref-referral.test.ts @@ -19,11 +19,22 @@ vi.mock("@chatbotx.io/database/client", () => ({ findOrFail, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flowModel.id" }, - flowVersionModel: { id: "flowVersionModel.id" }, - reflinkModel: { id: "reflinkModel.id" }, -})) +// `ref.ts` -> business/minigame -> ...-> contactService pulls in +// `queries/contact-filter`, which touches many unrelated schema tables at +// module scope. A Proxy sentinel satisfies vitest's "does this export +// exist" check for any table name without listing the whole schema. +vi.mock("@chatbotx.io/database/schema", () => { + const overrides = { + flowModel: { id: "flowModel.id" }, + flowVersionModel: { id: "flowVersionModel.id" }, + reflinkModel: { id: "reflinkModel.id" }, + } + return new Proxy(overrides, { + get: (target, prop) => + prop in target ? target[prop as keyof typeof target] : {}, + has: () => true, + }) +}) const emit = vi.fn() vi.mock("@chatbotx.io/event-bus", () => ({ emit })) diff --git a/apps/worker/__tests__/send-flow-step.test.ts b/apps/worker/__tests__/send-flow-step.test.ts index d6b47f242c..cfcdd46993 100644 --- a/apps/worker/__tests__/send-flow-step.test.ts +++ b/apps/worker/__tests__/send-flow-step.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryCreateWithAttachments, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockFindConversation, mockFindContactInbox, mockBroadcast, @@ -21,28 +19,14 @@ const { mockSendMessageToChannel, mockProcessWhatsappTemplate, mockProcessMessengerTemplate, - mockDbSet, - mockRecordOutboundMessage, + mockRecordOutboundFlowStep, + mockRecordOutboundMessageActivity, mockRecordSendFailure, mockInvalidateTracking, mockConversationInvalidate, - mockUpdateFlowStepState, mockFindAppointmentCalendarBySlug, mockSignAppointmentWebviewToken, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - const mockFindConversation = vi.fn() const mockFindContactInbox = vi.fn() @@ -86,8 +70,6 @@ const { mockRepositoryCreate, mockRepositoryCreateWithAttachments, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockFindConversation, mockFindContactInbox, mockBroadcast: vi.fn(), @@ -119,14 +101,15 @@ const { mockProcessMessengerTemplate: vi .fn() .mockResolvedValue({ messageId: "msg-ms" }), - mockDbSet, - mockRecordOutboundMessage: vi + mockRecordOutboundFlowStep: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockRecordOutboundMessageActivity: vi .fn() .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), mockConversationInvalidate: vi.fn().mockResolvedValue(undefined), - mockUpdateFlowStepState: vi.fn().mockResolvedValue(undefined), mockFindAppointmentCalendarBySlug: vi.fn(), mockSignAppointmentWebviewToken: vi.fn().mockResolvedValue("webview-token"), } @@ -163,23 +146,6 @@ vi.mock("@chatbotx.io/analytics", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - conversationModel: { findFirst: mockFindConversation }, - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -193,14 +159,16 @@ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, broadcastToGuestParty: vi.fn().mockResolvedValue(undefined), contactInboxService: { - recordOutboundMessageCreated: mockRecordOutboundMessage, - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), + findByUncached: mockFindContactInbox, + findRecentByContactId: mockFindContactInbox, recordSendFailure: mockRecordSendFailure, invalidateTracking: mockInvalidateTracking, }, conversationService: { + findByIdWithContactUnscoped: mockFindConversation, invalidate: mockConversationInvalidate, - updateFlowStepState: mockUpdateFlowStepState, + recordOutboundFlowStep: mockRecordOutboundFlowStep, + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, }, resolveTenantSettings: mockresolveTenantSettings, })) @@ -1000,33 +968,22 @@ describe("sendFlowStep", () => { expect(mockRepositoryCreate).not.toHaveBeenCalled() }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await sendFlowStep({ ...baseParams, step: sendTextStep }) - const { messageModel: messageModelMock } = await import( - "@chatbotx.io/database/schema" - ) - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("updates contact inbox lastMessageAt and conversation lastActivityAt after creating a flow message", async () => { await sendFlowStep({ ...baseParams, step: sendTextStep }) const createdMessage = await mockRepositoryCreate.mock.results[0]?.value - expect(mockRecordOutboundMessage).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordOutboundFlowStep).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", at: createdMessage.createdAt, - }) - expect(mockUpdateFlowStepState).toHaveBeenCalledWith({ - tx: expect.any(Object), - workspaceId: "ws-1", - conversationId: "conv-1", - lastActivityAt: createdMessage.createdAt, lastStep: undefined, currentStep: "step-1", }) @@ -1216,15 +1173,15 @@ describe("sendChatMessage", () => { }) const createdMessage = await mockRepositoryCreate.mock.results[0]?.value - expect(mockRecordOutboundMessage).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", at: createdMessage.createdAt, }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: createdMessage.createdAt, + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], }) }) diff --git a/apps/worker/__tests__/send-messenger-template-create.test.ts b/apps/worker/__tests__/send-messenger-template-create.test.ts index becfe3826f..377f0fe4c7 100644 --- a/apps/worker/__tests__/send-messenger-template-create.test.ts +++ b/apps/worker/__tests__/send-messenger-template-create.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast, mockEmit, mockValidateTemplate, @@ -17,24 +15,13 @@ const { mockContactVariables, mockSendFlowStep, mockRecordSendFailure, - mockDbSet, + mockRecordOutboundMessageActivity, + mockInvalidateTracking, + mockFindAnyActiveFlow, mockEnqueueIntegrationJob, mockFindSendableBroadcast, mockResetContactForResume, } = vi.hoisted(() => { - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - const mockRepositoryCreate = vi.fn().mockResolvedValue({ id: "msg-created", contactInboxId: "ci-1", @@ -60,8 +47,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), mockValidateTemplate: vi.fn().mockResolvedValue({ @@ -81,7 +66,11 @@ const { .fn() .mockResolvedValue({ messageIds: ["provider-msg-1"] }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), - mockDbSet, + mockRecordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), + mockFindAnyActiveFlow: vi.fn().mockResolvedValue(null), mockFindSendableBroadcast: vi.fn().mockResolvedValue({ id: "broadcast-1" }), mockResetContactForResume: vi.fn().mockResolvedValue(undefined), } @@ -95,22 +84,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - flowModel: { findFirst: vi.fn().mockResolvedValue(null) }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -120,12 +93,14 @@ vi.mock("@chatbotx.io/database/schema", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: mockRecordSendFailure, - invalidateTracking: vi.fn().mockResolvedValue(undefined), + invalidateTracking: mockInvalidateTracking, + }, + conversationService: { + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, + }, + flowService: { + findAnyActive: mockFindAnyActiveFlow, }, broadcastService: { findSendableBroadcast: mockFindSendableBroadcast, @@ -304,18 +279,14 @@ describe("processMessengerTemplate", () => { ) }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await processMessengerTemplate({ conversation: fakeConversation, contactInbox: fakeContactInbox, template: fakeTemplate, }) - const messageModelMock = (await import("@chatbotx.io/database/schema")) - .messageModel - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("broadcasts realtime event after message created", async () => { @@ -347,8 +318,16 @@ describe("processMessengerTemplate", () => { "ws-1", createdAt, ) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbSet).toHaveBeenCalledWith({ lastActivityAt: createdAt }) + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + contactId: undefined, + at: createdAt, + }) + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], + }) }) test("does not rethrow when persisting sourceId fails after a successful send", async () => { diff --git a/apps/worker/__tests__/send-messenger-template.test.ts b/apps/worker/__tests__/send-messenger-template.test.ts index 12e05b75d5..8f5df67815 100644 --- a/apps/worker/__tests__/send-messenger-template.test.ts +++ b/apps/worker/__tests__/send-messenger-template.test.ts @@ -85,13 +85,14 @@ vi.mock("@chatbotx.io/variables", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: vi.fn(), contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: vi.fn().mockResolvedValue(undefined), invalidateTracking: vi.fn().mockResolvedValue(undefined), }, + conversationService: { + recordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + }, })) vi.mock("../src/lib/logger", () => ({ @@ -211,10 +212,7 @@ describe("processMessengerTemplate — sourceId persistence", () => { template: TEMPLATE, }) - const setCall = mockDbUpdate.mock.results[0].value.set - expect(setCall).not.toHaveBeenCalledWith( - expect.objectContaining({ sourceId: expect.any(String) }), - ) + expect(mockDbUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/worker/__tests__/send-whatsapp-template.test.ts b/apps/worker/__tests__/send-whatsapp-template.test.ts index 1152ac34fa..16c7828f5f 100644 --- a/apps/worker/__tests__/send-whatsapp-template.test.ts +++ b/apps/worker/__tests__/send-whatsapp-template.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast, mockEmit, mockValidateTemplate, @@ -19,24 +17,12 @@ const { mockConvertButtons, mockParseSdkError, mockRecordSendFailure, - mockDbSet, + mockRecordOutboundMessageActivity, + mockInvalidateTracking, mockEnqueueIntegrationJob, mockFindSendableBroadcast, mockResetContactForResume, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - const mockRepositoryCreate = vi.fn().mockResolvedValue({ id: "msg-created", contactInboxId: "ci-1", @@ -63,8 +49,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), mockValidateTemplate: vi.fn().mockResolvedValue({ @@ -84,7 +68,10 @@ const { mockConvertButtons: vi.fn().mockReturnValue([]), mockParseSdkError: vi.fn().mockResolvedValue({ message: "sdk error" }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), - mockDbSet, + mockRecordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), mockEnqueueIntegrationJob: vi.fn().mockResolvedValue(undefined), mockFindSendableBroadcast: vi.fn().mockResolvedValue({ id: "broadcast-1" }), mockResetContactForResume: vi.fn().mockResolvedValue(undefined), @@ -99,22 +86,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - conversationModel: { findFirst: vi.fn().mockResolvedValue(null) }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -131,12 +102,11 @@ vi.mock("@chatbotx.io/worker-config", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: mockRecordSendFailure, - invalidateTracking: vi.fn().mockResolvedValue(undefined), + invalidateTracking: mockInvalidateTracking, + }, + conversationService: { + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, }, broadcastService: { findSendableBroadcast: mockFindSendableBroadcast, @@ -307,18 +277,14 @@ describe("processWhatsappTemplate", () => { ) }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await processWhatsappTemplate({ conversation: fakeConversation, contactInbox: fakeContactInbox, template: fakeTemplate, }) - const messageModelMock = (await import("@chatbotx.io/database/schema")) - .messageModel - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("broadcasts realtime event after message created", async () => { @@ -399,8 +365,16 @@ describe("processWhatsappTemplate", () => { "ws-1", createdAt, ) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbSet).toHaveBeenCalledWith({ lastActivityAt: createdAt }) + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + contactId: undefined, + at: createdAt, + }) + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], + }) }) test("does not rethrow when persisting sourceId fails after a successful send", async () => { diff --git a/apps/worker/__tests__/sequence-flow.test.ts b/apps/worker/__tests__/sequence-flow.test.ts index ad85c8ed9e..2f6c944d60 100644 --- a/apps/worker/__tests__/sequence-flow.test.ts +++ b/apps/worker/__tests__/sequence-flow.test.ts @@ -1,47 +1,6 @@ import type { Job } from "bullmq" import { beforeEach, describe, expect, test, vi } from "vitest" -// ---------- db chain spies ---------- -const dbFindFirstSpy = vi.fn() -const dbUpdateSpy = vi.fn() -const dbSetSpy = vi.fn() -const dbWhereSpy = vi.fn() -const dbWhereResolveSpy = vi.fn() - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - sequenceDispatchModel: { - findFirst: (...args: unknown[]) => dbFindFirstSpy(...args), - }, - }, - update: (table: unknown) => { - dbUpdateSpy(table) - return { - set: (values: unknown) => { - dbSetSpy(values) - return { - where: (...args: unknown[]) => { - dbWhereSpy(...args) - return dbWhereResolveSpy(...args) - }, - } - }, - } - }, - }, - and: (...args: unknown[]) => ({ __and: args }), - eq: (col: unknown, val: unknown) => ({ __eq: [col, val] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceDispatchModel: { - id: { __col: "id" }, - workspaceId: { __col: "workspaceId" }, - status: { __col: "status" }, - }, -})) - // ---------- scheduler / redis spies ---------- // removeFromScheduleSpy is accessed at instance-creation time (during tests, // not at import time), so vi.hoisted is not needed. @@ -66,6 +25,24 @@ vi.mock("@chatbotx.io/sequence-scheduler", () => ({ advanceEnrollment: (...args: unknown[]) => advanceEnrollmentSpy(...args), })) +// ---------- contactSequenceService spies ---------- +// sequence-flow.ts now delegates all dispatch persistence to +// contactSequenceService.{findRunningDispatch,markDispatchCompleted, +// markDispatchCanceled,markDispatchFailed}. +const findRunningSpy = vi.fn() +const markCompletedSpy = vi.fn() +const markCanceledSpy = vi.fn() +const markFailedSpy = vi.fn() + +vi.mock("@chatbotx.io/business/contact-sequence", () => ({ + contactSequenceService: { + findRunningDispatch: (...args: unknown[]) => findRunningSpy(...args), + markDispatchCompleted: (...args: unknown[]) => markCompletedSpy(...args), + markDispatchCanceled: (...args: unknown[]) => markCanceledSpy(...args), + markDispatchFailed: (...args: unknown[]) => markFailedSpy(...args), + }, +})) + // ---------- step executor spy (module-level singleton in source) ---------- // Must use vi.hoisted() so the spies exist before the class field initializers // fire at module-import time (sequence-flow.ts does `new StepExecutorService()` @@ -152,9 +129,11 @@ function makeStep(overrides: Record = {}) { } beforeEach(() => { - // db defaults - dbFindFirstSpy.mockResolvedValue(makeDispatch()) - dbWhereResolveSpy.mockResolvedValue(undefined) + // sequence-scheduler defaults + findRunningSpy.mockResolvedValue(makeDispatch()) + markCompletedSpy.mockResolvedValue(undefined) + markCanceledSpy.mockResolvedValue(undefined) + markFailedSpy.mockResolvedValue(undefined) // scheduler defaults removeFromScheduleSpy.mockResolvedValue(undefined) @@ -185,11 +164,13 @@ describe("handleSendSequenceFlow", () => { contactId: "contact-1", }), ) - const setArg = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "completed", + expect(markCompletedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + sentAt: expect.any(Date), + }), ) - expect(setArg).toBeDefined() }) test("calls advanceEnrollment with correct enrollment + step info", async () => { @@ -222,7 +203,7 @@ describe("handleSendSequenceFlow", () => { test("skips sendFlowDirect and reuses the existing sentAt", async () => { // Arrange const completedAt = new Date("2025-01-01T10:00:00Z") - dbFindFirstSpy.mockResolvedValue(makeDispatch({ completedAt })) + findRunningSpy.mockResolvedValue(makeDispatch({ completedAt })) // Act await handleSendSequenceFlow(makeData(), makeJob()) @@ -238,13 +219,15 @@ describe("handleSendSequenceFlow", () => { describe("dispatch not found", () => { test("returns early without touching db, scheduler, or advanceEnrollment", async () => { // Arrange - dbFindFirstSpy.mockResolvedValue(undefined) + findRunningSpy.mockResolvedValue(undefined) // Act await handleSendSequenceFlow(makeData(), makeJob()) // Assert - expect(dbUpdateSpy).not.toHaveBeenCalled() + expect(markCompletedSpy).not.toHaveBeenCalled() + expect(markCanceledSpy).not.toHaveBeenCalled() + expect(markFailedSpy).not.toHaveBeenCalled() expect(sendFlowDirectSpy).not.toHaveBeenCalled() expect(advanceEnrollmentSpy).not.toHaveBeenCalled() expect(removeFromScheduleSpy).not.toHaveBeenCalled() @@ -263,15 +246,13 @@ describe("handleSendSequenceFlow", () => { await handleSendSequenceFlow(makeData(), makeJob()) // Assert - const canceledSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "canceled", + expect(markCanceledSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + reason: "step_inactive", + }), ) - expect(canceledSet).toBeDefined() - expect((canceledSet as unknown[])[0]).toMatchObject({ - status: "canceled", - lastError: "step_inactive", - }) }) test("still calls advanceEnrollment so enrollment progresses past the dead step", async () => { @@ -313,11 +294,13 @@ describe("handleSendSequenceFlow", () => { await handleSendSequenceFlow(makeData(), makeJob()) // Assert - const canceledSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "canceled", + expect(markCanceledSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + reason: "step_not_found", + }), ) - expect(canceledSet).toBeDefined() }) test("does NOT call advanceEnrollment — no step to advance past", async () => { @@ -352,14 +335,13 @@ describe("handleSendSequenceFlow", () => { ) // Assert - const failedSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => (c[0] as Record).status === "failed", + expect(markFailedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + errorMessage: "send failed", + }), ) - expect(failedSet).toBeDefined() - expect((failedSet as unknown[])[0]).toMatchObject({ - status: "failed", - lastError: "send failed", - }) }) test("removes dispatch from schedule during terminal cleanup", async () => { @@ -399,10 +381,7 @@ describe("handleSendSequenceFlow", () => { ) // Assert — no terminal cleanup - const failedSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => (c[0] as Record).status === "failed", - ) - expect(failedSet).toBeUndefined() + expect(markFailedSpy).not.toHaveBeenCalled() }) test("logs the error with attempt and isFinalAttempt info", async () => { diff --git a/apps/worker/src/ai-agent/handlers/summarize-conversation.ts b/apps/worker/src/ai-agent/handlers/summarize-conversation.ts index b98353f35e..f8dabc1099 100644 --- a/apps/worker/src/ai-agent/handlers/summarize-conversation.ts +++ b/apps/worker/src/ai-agent/handlers/summarize-conversation.ts @@ -5,7 +5,7 @@ import { isSameContextMessage, summarizeConversation, } from "@chatbotx.io/ai/server" -import { db } from "@chatbotx.io/database/client" +import { conversationService } from "@chatbotx.io/business" import { AIJobAction, type AIJobSummarizeConversation, @@ -124,7 +124,7 @@ export async function handleSummarizeConversation( return } - const conversation = await db.query.conversationModel.findFirst({ + const conversation = await conversationService.findBy({ where: { id: conversationId }, }) diff --git a/apps/worker/src/chat/handlers/send-flow-step.ts b/apps/worker/src/chat/handlers/send-flow-step.ts index 9f860bef57..735b7e3b5b 100644 --- a/apps/worker/src/chat/handlers/send-flow-step.ts +++ b/apps/worker/src/chat/handlers/send-flow-step.ts @@ -13,7 +13,6 @@ import { resolveTenantSettings, } from "@chatbotx.io/business" import { getPublicFileUrl } from "@chatbotx.io/business/utils" -import { db, eq } from "@chatbotx.io/database/client" import { channelTypes, contentTypes, @@ -24,10 +23,7 @@ import { createMessageRepository, type MessageWithAttachments, } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { AttachmentModel, MessageModel } from "@chatbotx.io/database/types" import { signAppointmentWebviewToken } from "@chatbotx.io/encryption" import { emit } from "@chatbotx.io/event-bus" @@ -109,29 +105,21 @@ const extractBookingPublicLinkSlug = (url: string): string | null => { } const findTargetContactInbox = ({ + workspaceId, contactId, contactInboxId, }: { + workspaceId: string contactId: string contactInboxId?: string }) => { if (contactInboxId) { - return db.query.contactInboxModel.findFirst({ - where: { - id: contactInboxId, - contactId, - }, + return contactInboxService.findByUncached({ + where: { id: contactInboxId, contactId }, }) } - return db.query.contactInboxModel.findFirst({ - where: { - contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, - }) + return contactInboxService.findRecentByContactId({ workspaceId, contactId }) } export const convertButtonsToTemplate = (props: { @@ -338,15 +326,15 @@ export async function sendFlowStep({ commentAnchor, appointmentId, }: ChatJobSendFlowStep["data"]) { - const conversation = await db.query.conversationModel.findFirst({ - where: { id: conversationId }, - with: { contact: true }, + const conversation = await conversationService.findByIdWithContactUnscoped({ + id: conversationId, }) if (!conversation) { return } const targetContactInbox = await findTargetContactInbox({ + workspaceId: conversation.workspaceId, contactId: conversation.contactId, contactInboxId, }) @@ -657,27 +645,16 @@ export async function sendFlowStep({ } const createdMessage = message - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: targetContactInbox.id, - contactId: targetContactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await conversationService.updateFlowStepState({ - tx, + const trackingInvalidation = + await conversationService.recordOutboundFlowStep({ workspaceId: conversation.workspaceId, conversationId: conversation.id, - lastActivityAt: createdMessage.createdAt, + contactInboxId: targetContactInbox.id, + contactId: targetContactInbox.contactId, + at: createdMessage.createdAt, lastStep: conversation.currentStep, currentStep: resolvedStep.id, }) - - return invalidation - }) await Promise.all([ trackingInvalidation ? contactInboxService.invalidateTracking(trackingInvalidation) @@ -886,13 +863,9 @@ export const sendChatMessage = async ( const contactInbox = targetContactInbox ?? - (await db.query.contactInboxModel.findFirst({ - where: { - contactId: conversation.contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId: conversation.workspaceId, + contactId: conversation.contactId, })) if (!contactInbox) { throw new IntegrationException( @@ -977,23 +950,14 @@ export const sendChatMessage = async ( })) } - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: message.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: message.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: message.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } diff --git a/apps/worker/src/chat/handlers/send-messenger-template.ts b/apps/worker/src/chat/handlers/send-messenger-template.ts index b7ea6f0cc7..b53d2b6ef4 100644 --- a/apps/worker/src/chat/handlers/send-messenger-template.ts +++ b/apps/worker/src/chat/handlers/send-messenger-template.ts @@ -1,13 +1,11 @@ import { broadcastToWorkspaceParty, contactInboxService, + conversationService, + flowService, } from "@chatbotx.io/business" -import { db, eq } from "@chatbotx.io/database/client" import { createMessageRepository } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, ConversationModel, @@ -204,23 +202,14 @@ export async function processMessengerTemplate( }) const createdMessage = newMessage - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: createdMessage.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: createdMessage.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } @@ -429,8 +418,8 @@ export async function sendMessengerTemplateMessage( // nodes, so getNodeFromButton returns undefined → graceful no-op return. // This mirrors how send-text step buttons with no action work. const contextFlow = storedButtons?.some((b) => !b.flowId) - ? await db.query.flowModel.findFirst({ - where: { workspaceId: conversation.workspaceId, active: true }, + ? await flowService.findAnyActive({ + workspaceId: conversation.workspaceId, }) : null diff --git a/apps/worker/src/chat/handlers/send-whatsapp-template.ts b/apps/worker/src/chat/handlers/send-whatsapp-template.ts index 018360ea71..41e4eb996f 100644 --- a/apps/worker/src/chat/handlers/send-whatsapp-template.ts +++ b/apps/worker/src/chat/handlers/send-whatsapp-template.ts @@ -1,13 +1,10 @@ import { broadcastToWorkspaceParty, contactInboxService, + conversationService, } from "@chatbotx.io/business" -import { db, eq } from "@chatbotx.io/database/client" import { createMessageRepository } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, ConversationModel, @@ -298,23 +295,14 @@ export async function processWhatsappTemplate( } const createdMessage = newMessage - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: createdMessage.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: createdMessage.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } diff --git a/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts b/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts index 599cf0b619..2a825de1c7 100644 --- a/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts +++ b/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts @@ -1,33 +1,21 @@ // biome-ignore-all lint/suspicious/noBitwiseOperators: bit-packing 63-bit snowflake IDs import { + coexistImportService, contactInboxService, conversationService, - messageCleanupService, workspaceUsageService, } from "@chatbotx.io/business" -import { buildContactInboxIdentityWhere } from "@chatbotx.io/business/contact-inbox" -import { - and, - type DatabaseClient, - db, - describeDatabaseError, - eq, - inArray, - sql, -} from "@chatbotx.io/database/client" -import { contactSources } from "@chatbotx.io/database/partials" +import { describeDatabaseError } from "@chatbotx.io/database/client" import type { BulkCreateAttachmentInput, CreateMessageInput, IMessageRepository, } from "@chatbotx.io/database/repositories" -import { createMessageRepository } from "@chatbotx.io/database/repositories" import { - contactInboxModel, - contactModel, - conversationModel, -} from "@chatbotx.io/database/schema" + contactRepository, + createMessageRepository, +} from "@chatbotx.io/database/repositories" import type { InboxModel } from "@chatbotx.io/database/types" import { emit } from "@chatbotx.io/event-bus" import { emitContactCreated } from "@chatbotx.io/events" @@ -446,8 +434,8 @@ const mergeConversationUpdate = ( * row can exist purely to carry `aiMarkerMessageId` — but still reach the * Conversation update so the marker still advances. * - * The conversation UPDATE routes through `conversationService` (not raw - * `db.execute`) so it gets the same advance-only, NULL-guarded semantics for + * The conversation UPDATE routes through `conversationService` (not a raw + * SQL execute) so it gets the same advance-only, NULL-guarded semantics for * both columns plus the required cache invalidation. Failures must * propagate: otherwise a coexist run can be marked succeeded while these * denormalized activity columns remain null or stuck at row-creation time. @@ -540,65 +528,6 @@ export type BulkImportHistoricalResult = { * created). Callers use this map to dispatch downstream avatar / message * fetches without an additional DB lookup. */ -type ContactInboxIdentityRow = { - id: string - sourceId: string - sourceUserId: string | null - contactId: string -} - -const rowsBySourceUserId = ( - rows: readonly T[], -): Map => - new Map( - rows.flatMap((row) => - row.sourceUserId === null ? [] : [[row.sourceUserId, row] as const], - ), - ) - -/** - * Resolves raced entries whose ContactInbox insert was skipped by the partial - * (inboxId, sourceUserId) unique index: finds the winner row owning each - * entry's scoped user id, keyed by the entry's own import sourceId so the - * caller can alias the import key to the winner's link. - */ -const resolveScopedIdRaceWinners = async (props: { - tx: DatabaseClient - inboxId: string - racedEntries: ReadonlyArray -}): Promise> => { - const { tx, inboxId, racedEntries } = props - if (racedEntries.length === 0) { - return new Map() - } - const winners = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - sourceUserId: contactInboxModel.sourceUserId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - and( - eq(contactInboxModel.inboxId, inboxId), - inArray( - contactInboxModel.sourceUserId, - racedEntries.map(([, scopedId]) => scopedId), - ), - ), - ) - const winnerByScopedId = rowsBySourceUserId(winners) - const aliases = new Map() - for (const [entrySourceId, scopedId] of racedEntries) { - const winner = winnerByScopedId.get(scopedId) - if (winner) { - aliases.set(entrySourceId, winner) - } - } - return aliases -} - export const bulkImportContacts = async (props: { inbox: InboxModel workspaceId: string @@ -646,23 +575,8 @@ export const bulkImportContacts = async (props: { } const sourceIds = [...dedup.keys()] - const newContactCreatedEvents: Array<{ - workspaceId: string - contactId: string - contactInboxId: string - sourceId: string - firstName?: string - phoneNumber?: string - email?: string - channel: string - source: string - createdAt: Date - }> = [] - - let importedContacts = 0 const skippedContacts = 0 const failureReason: string | undefined = undefined - const contactInboxIds = new Map() // A thread's scoped user id (e.g. a WhatsApp BSUID) may already belong to a // row in this inbox under a different sourceId. Matching on it up front @@ -672,301 +586,15 @@ export const bulkImportContacts = async (props: { entry.sourceUserId ? [entry.sourceUserId] : [], ) - await db.transaction(async (tx) => { - // 1. Find existing ContactInbox rows — by sourceId or scoped user id. - const existingRows = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - sourceUserId: contactInboxModel.sourceUserId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - buildContactInboxIdentityWhere({ - inboxId: inbox.id, - sourceIds, - sourceUserIds, - }), - ) - - const resolved = new Map() - const existingContactIds = new Set() - - for (const row of existingRows) { - existingContactIds.add(row.contactId) - resolved.set(row.sourceId, { - contactInboxId: row.id, - contactId: row.contactId, - conversationId: "", - }) - } - - const existingBySourceUserId = rowsBySourceUserId(existingRows) - for (const [sourceId, entry] of dedup) { - if (resolved.has(sourceId) || !entry.sourceUserId) { - continue - } - const row = existingBySourceUserId.get(entry.sourceUserId) - if (!row) { - continue - } - existingContactIds.add(row.contactId) - resolved.set(sourceId, { - contactInboxId: row.id, - contactId: row.contactId, - conversationId: "", - }) - } - - // Resolve conversation ids for existing contacts. Heal orphans (existing - // ContactInbox + Contact but missing Conversation) by inserting one now, - // so downstream callers never receive an empty conversationId. - if (existingContactIds.size > 0) { - const conversations = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, [...existingContactIds])) - const convByContact = new Map( - conversations.map((c) => [c.contactId, c.id]), - ) - - const orphanContactIds = [...existingContactIds].filter( - (cid) => !convByContact.has(cid), - ) - if (orphanContactIds.length > 0) { - await tx - .insert(conversationModel) - .values( - orphanContactIds.map((cid) => ({ - id: createId(), - workspaceId, - contactId: cid, - })), - ) - .onConflictDoNothing() - const healed = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, orphanContactIds)) - for (const c of healed) { - convByContact.set(c.contactId, c.id) - } - } - - for (const link of resolved.values()) { - const cid = convByContact.get(link.contactId) - if (cid) { - link.conversationId = cid - } - } - } - - const newEntries = [...dedup.entries()].filter( - ([sourceId]) => !resolved.has(sourceId), - ) - const acceptedNew = newEntries - - // 2. Insert Contact + ContactInbox + Conversation for acceptedNew. - if (acceptedNew.length > 0) { - const contactRows = acceptedNew.map(([, entry]) => ({ - id: createId(), - workspaceId, - firstName: entry.firstName, - lastName: entry.lastName, - email: entry.email, - phoneNumber: entry.phoneNumber, - avatar: entry.avatar, - })) - - await tx.insert(contactModel).values(contactRows) - - const contactInboxRows = acceptedNew.map(([sourceId, entry], i) => ({ - id: createId(), - inboxId: inbox.id, - contactId: contactRows[i]?.id, - originalContactId: contactRows[i]?.id, - source: contactSources.enum.inboundMessage, - sourceId, - sourceUserId: entry.sourceUserId ?? null, - sourceUsername: entry.sourceUsername ?? null, - channel: inbox.channel, - createdAt: new Date(), - updatedAt: new Date(), - })) - - const conversationRows = acceptedNew.map((_entry, i) => ({ - id: createId(), - workspaceId, - contactId: contactRows[i]?.id, - })) - - // Targetless DO NOTHING: a concurrent import can win EITHER identity - // index — (inboxId, sourceId) or the partial (inboxId, sourceUserId) — - // and a targeted clause would let the second one abort the whole batch. - const insertedInboxes = await tx - .insert(contactInboxModel) - .values(contactInboxRows) - .onConflictDoNothing() - .returning({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - contactId: contactInboxModel.contactId, - }) - - const insertedSourceIds = new Set(insertedInboxes.map((r) => r.sourceId)) - - // Race recovery — any acceptedNew sourceId not inserted lost to a - // concurrent insert; re-SELECT winners + delete pre-allocated orphans. - const racedSourceIds = acceptedNew - .map(([sourceId]) => sourceId) - .filter((s) => !insertedSourceIds.has(s)) - - // Maps a raced entry's import key to the winner row that claimed its - // scoped user id under a DIFFERENT sourceId — the final link mapping is - // keyed by row.sourceId, so these aliases are re-keyed at the end. - let scopedWinnerAliases = new Map() - - if (racedSourceIds.length > 0) { - const winners = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - and( - eq(contactInboxModel.inboxId, inbox.id), - inArray(contactInboxModel.sourceId, racedSourceIds), - ), - ) - for (const w of winners) { - insertedInboxes.push(w) - insertedSourceIds.add(w.sourceId) - } - - // A raced row skipped on the scoped-user-id index has no winner under - // its own sourceId — resolve it through the row owning that scoped id. - scopedWinnerAliases = await resolveScopedIdRaceWinners({ - tx, - inboxId: inbox.id, - racedEntries: racedSourceIds.flatMap((sourceId) => { - if (insertedSourceIds.has(sourceId)) { - return [] - } - const scopedId = dedup.get(sourceId)?.sourceUserId - return scopedId ? [[sourceId, scopedId] as const] : [] - }), - }) - for (const winner of scopedWinnerAliases.values()) { - insertedInboxes.push({ - id: winner.id, - sourceId: winner.sourceId, - contactId: winner.contactId, - }) - } - - const racedSet = new Set(racedSourceIds) - const orphanIds: string[] = [] - for (let i = 0; i < acceptedNew.length; i++) { - const sourceId = acceptedNew[i]?.[0] - const contactId = contactRows[i]?.id - if (sourceId && contactId && racedSet.has(sourceId)) { - orphanIds.push(contactId) - } - } - if (orphanIds.length > 0) { - await tx - .delete(contactModel) - .where(inArray(contactModel.id, orphanIds)) - } - } - - // Re-created contacts keep their history: cancel any pending message - // cleanup recorded when contacts with these inbox identities were deleted. - await messageCleanupService.cancelByInboxSource({ - inboxId: inbox.id, - sourceIds: insertedInboxes.map((r) => r.sourceId), - tx, - }) - - const trulyNew = acceptedNew.length - racedSourceIds.length - importedContacts = trulyNew - - const racedSet2 = new Set(racedSourceIds) - const conversationsToInsert = conversationRows.filter( - (_row, i) => !racedSet2.has(acceptedNew[i]?.[0]), - ) - if (conversationsToInsert.length > 0) { - await tx - .insert(conversationModel) - .values(conversationsToInsert) - .onConflictDoNothing() - } - - // Resolve conversation ids for everything just inserted (or raced). - const acceptedContactIds = insertedInboxes.map((r) => r.contactId) - const newConversations = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, acceptedContactIds)) - const convByContactNew = new Map( - newConversations.map((c) => [c.contactId, c.id]), - ) - - for (const inboxRow of insertedInboxes) { - const convId = convByContactNew.get(inboxRow.contactId) - if (!convId) { - continue - } - resolved.set(inboxRow.sourceId, { - contactInboxId: inboxRow.id, - contactId: inboxRow.contactId, - conversationId: convId, - }) - - const entry = dedup.get(inboxRow.sourceId) - if (entry) { - newContactCreatedEvents.push({ - workspaceId, - contactId: inboxRow.contactId, - contactInboxId: inboxRow.id, - sourceId: inboxRow.sourceId, - firstName: entry.firstName, - phoneNumber: entry.phoneNumber, - email: entry.email, - channel: inbox.channel, - source: contactSources.enum.inboundMessage, - createdAt: new Date(), - }) - } - } - - // Scoped-id winners resolve under their own sourceId above; alias the - // raced entry's import key to the same link so downstream message - // imports keyed by the entry's sourceId still find their contact. - for (const [entrySourceId, winner] of scopedWinnerAliases) { - const link = resolved.get(winner.sourceId) - if (link) { - resolved.set(entrySourceId, link) - } - } - } - - for (const [sourceId, link] of resolved) { - contactInboxIds.set(sourceId, link) - } - }) + const { importedContacts, contactInboxIds, newContactCreatedEvents } = + await coexistImportService.resolveOrCreateContactLinks({ + workspaceId, + inboxId: inbox.id, + inboxChannel: inbox.channel, + dedup, + sourceIds, + sourceUserIds, + }) // Post-commit side effects. for (const ev of newContactCreatedEvents) { @@ -1200,19 +828,13 @@ export const bulkImportMessages = async (props: { } } - // Contact enrichment in its own main-DB transaction. + // Contact enrichment — routed through the repository so it gets the exact + // same COALESCE/WHERE-guarded semantics in its own main-DB transaction. if (hasEnrichment && contactEnrichment) { - await db.transaction(async (tx) => { - await tx.execute(sql` - UPDATE "Contact" SET - "phoneNumber" = COALESCE("phoneNumber", ${contactEnrichment.phoneNumber ?? null}::text), - "email" = COALESCE("email", ${contactEnrichment.email ?? null}::text) - WHERE "id" = ${contactId} - AND ( - (${contactEnrichment.phoneNumber ?? null}::text IS NOT NULL AND "phoneNumber" IS NULL) - OR (${contactEnrichment.email ?? null}::text IS NOT NULL AND "email" IS NULL) - ) - `) + await contactRepository.enrichIfNull({ + contactId, + phoneNumber: contactEnrichment.phoneNumber, + email: contactEnrichment.email, }) } diff --git a/apps/worker/src/integration/handlers/coexist/instagram-sync.ts b/apps/worker/src/integration/handlers/coexist/instagram-sync.ts index 6cf5defa65..77136304c2 100644 --- a/apps/worker/src/integration/handlers/coexist/instagram-sync.ts +++ b/apps/worker/src/integration/handlers/coexist/instagram-sync.ts @@ -65,7 +65,7 @@ const runInstagramCoexistPull = async < await failRun("Instagram integration not found or coexist disabled") return } - const claimed = await coexistService.claimRun({ runId }) + const claimed = await coexistService.claimRunWithNewToken({ runId }) if (!claimed) { logger.warn( { runId, integrationId }, diff --git a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts index 2b4a661724..ddaa90b3dc 100644 --- a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts +++ b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts @@ -1,21 +1,12 @@ -import { logProviderError } from "@chatbotx.io/business/error-log" -import { - and, - db, - eq, - findOrFail, - inArray, - lt, - ne, - or, - sql, -} from "@chatbotx.io/database/client" import { - coexistSyncRunModel, - contactInboxModel, - conversationModel, - inboxModel, -} from "@chatbotx.io/database/schema" + coexistImportService, + coexistService, + messengerIntegrationService, + workspaceService, +} from "@chatbotx.io/business" +import { logProviderError } from "@chatbotx.io/business/error-log" +import { findOrFail } from "@chatbotx.io/database/client" +import { inboxModel } from "@chatbotx.io/database/schema" import { listConversations, type MessengerConversation, @@ -68,33 +59,6 @@ const DEFAULT_CONCURRENCY = 5 */ const CHUNK_BUDGET_MS = 4 * 60 * 1000 -/** - * Resolves the per-integration resume ceiling from the most recent prior - * `CoexistSyncRun` row. See bulk-historical-import for full semantics. - */ -async function fetchPriorRunCeiling( - integrationId: string, - currentRunId: string, -): Promise { - const priorRun = await db.query.coexistSyncRunModel.findFirst({ - where: { - integrationId, - channel: "messenger", - status: { in: ["succeeded", "partial"] }, - id: { ne: currentRunId }, - }, - orderBy: { startedAt: "desc" }, - columns: { startedAt: true, lastSyncedAt: true, status: true }, - }) - if (!priorRun) { - return null - } - if (priorRun.status === "succeeded") { - return priorRun.startedAt ?? null - } - return priorRun.lastSyncedAt ?? priorRun.startedAt ?? null -} - type ConvFilter = { convsToProcess: MessengerConversation[] stopAll: boolean @@ -237,14 +201,13 @@ async function walkConversationsPages( // final page and the loop will exit without a subsequent respectPause(). await ctx.respectPause() - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { currentStep: `phase=${phaseName} page ${pageNumber} — ${conversations.data.length} conversations`, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) const filtered = filterConversations( conversations.data, @@ -275,11 +238,7 @@ async function walkConversationsPages( async function runContactsPhase(ctx: SyncContext): Promise { const { runId, workspaceId, pageId, inbox } = ctx - const [runRow] = await db - .select({ lastSyncedAt: coexistSyncRunModel.lastSyncedAt }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const runRow = await coexistService.findLastSyncedAt({ runId }) if (!runRow) { return { done: true, oldestConvProcessed: null, pageNumber: 0 } @@ -362,19 +321,20 @@ async function runContactsPhase(ctx: SyncContext): Promise { ctx.errorRef.current = `phase=contacts page ${pageNumber}: ${pageResult.failureReason}` } - await db - .update(coexistSyncRunModel) - .set({ - currentScan: sql`${coexistSyncRunModel.currentScan} + ${filtered.convsToProcess.length}`, - importedContactCount: sql`${coexistSyncRunModel.importedContactCount} + ${pageResult.importedContacts}`, - skippedCount: sql`${coexistSyncRunModel.skippedCount} + ${pageResult.skippedContacts}`, + await coexistService.incrementProgress({ + runId, + increments: { + currentScan: filtered.convsToProcess.length, + importedContactCount: pageResult.importedContacts, + skippedCount: pageResult.skippedContacts, + }, + fields: { lastSyncedAt: filtered.oldestConvProcessed, currentStep: `phase=contacts page ${pageNumber} processed`, currentError: ctx.errorRef.current ?? null, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) return filtered.oldestConvProcessed }, @@ -390,11 +350,7 @@ async function runContactsPhase(ctx: SyncContext): Promise { async function runMessagesPhase(ctx: SyncContext): Promise { const { runId, workspaceId, pageId, inbox } = ctx - const [runRow] = await db - .select({ lastSyncedAt: coexistSyncRunModel.lastSyncedAt }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const runRow = await coexistService.findLastSyncedAt({ runId }) if (!runRow) { return { done: true, oldestConvProcessed: null, pageNumber: 0 } @@ -426,24 +382,10 @@ async function runMessagesPhase(ctx: SyncContext): Promise { // Single JOIN resolves ContactInbox + Conversation in one round trip. const linkBySource = new Map() if (sourceIds.length > 0) { - const rows = await db - .select({ - sourceId: contactInboxModel.sourceId, - contactInboxId: contactInboxModel.id, - contactId: contactInboxModel.contactId, - conversationId: conversationModel.id, - }) - .from(contactInboxModel) - .leftJoin( - conversationModel, - eq(conversationModel.contactId, contactInboxModel.contactId), - ) - .where( - and( - eq(contactInboxModel.inboxId, inbox.id), - inArray(contactInboxModel.sourceId, sourceIds), - ), - ) + const rows = await coexistImportService.listContactLinksBySourceIds({ + inboxId: inbox.id, + sourceIds, + }) for (const r of rows) { if (!r.conversationId) { continue @@ -615,19 +557,20 @@ async function runMessagesPhase(ctx: SyncContext): Promise { } } - await db - .update(coexistSyncRunModel) - .set({ - importedMessageCount: sql`${coexistSyncRunModel.importedMessageCount} + ${pageImported}`, - skippedCount: sql`${coexistSyncRunModel.skippedCount} + ${pageSkipped}`, - failedCount: sql`${coexistSyncRunModel.failedCount} + ${pageFailed}`, + await coexistService.incrementProgress({ + runId, + increments: { + importedMessageCount: pageImported, + skippedCount: pageSkipped, + failedCount: pageFailed, + }, + fields: { lastSyncedAt: pageOldest, currentStep: `phase=messages page ${pageNumber} processed`, currentError: ctx.errorRef.current ?? null, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) return pageOldest }, @@ -656,19 +599,14 @@ export const coexistMessengerSync = async ( const jobStart = Date.now() const failRun = async (currentError: string): Promise => { - await db - .update(coexistSyncRunModel) - .set({ - status: "failed", - currentError, - finishedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + await coexistService.markFailed({ runId, currentError }) } - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { id: integrationId }, + // NO workspace scope — the mismatch branch below deliberately distinguishes + // "not found" from "workspaceId mismatch" (do NOT substitute a + // workspace-scoped lookup here, which would collapse that distinction). + const integration = await messengerIntegrationService.findById({ + id: integrationId, }) if (!integration) { logger.warn({ integrationId }, "[coexist] Messenger integration gone") @@ -711,54 +649,30 @@ export const coexistMessengerSync = async ( message: "Inbox not found", }) - const workspace = await db.query.workspaceModel.findFirst({ - where: { id: workspaceId }, - columns: { targetCountry: true }, - }) + const workspace = await workspaceService.find({ where: { id: workspaceId } }) const defaultCountry = workspace?.targetCountry ?? null - const [initRow] = await db - .select({ - attempts: coexistSyncRunModel.attempts, - currentError: coexistSyncRunModel.currentError, - messengerSyncPhase: coexistSyncRunModel.messengerSyncPhase, - }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const initRow = await coexistService.findInitState({ runId }) if (!initRow) { logger.warn({ runId }, "[coexist] CoexistSyncRun row gone — abandoning") return } - const ceiling = await fetchPriorRunCeiling(integrationId, runId) + const ceiling = await coexistService.findResumeCeiling({ + integrationId, + channel: "messenger", + currentRunId: runId, + }) const attempts = initRow.attempts // Optimistic claim: only one worker may flip status→running at a time. - const claimed = await db - .update(coexistSyncRunModel) - .set({ - status: "running", - startedAt: sql`COALESCE(${coexistSyncRunModel.startedAt}, NOW())`, - lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(coexistSyncRunModel.id, runId), - or( - ne(coexistSyncRunModel.status, "running"), - lt( - coexistSyncRunModel.lastHeartbeatAt, - sql`NOW() - INTERVAL '10 minutes'`, - ), - ), - ), - ) - .returning({ id: coexistSyncRunModel.id }) + const claimedRun = await coexistService.reclaimRunForRetry({ + runId, + touchUpdatedAt: true, + }) - if (claimed.length === 0) { + if (!claimedRun) { logger.warn( { runId, integrationId }, "[coexist] Messenger run already claimed by another worker — abandoning", @@ -853,16 +767,15 @@ export const coexistMessengerSync = async ( break } // Transition to phase 2 — reset frontier so messages walks from newest. - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { messengerSyncPhase: "messages", lastSyncedAt: null, currentStep: "contacts done — start message phase", lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) currentPhase = "messages" continue } @@ -876,23 +789,15 @@ export const coexistMessengerSync = async ( } // Both phases complete — derive terminal status from counters. - const [terminal] = await db - .select({ - importedMessages: coexistSyncRunModel.importedMessageCount, - skipped: coexistSyncRunModel.skippedCount, - failed: coexistSyncRunModel.failedCount, - }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const terminal = await coexistService.findTerminalCounters({ runId }) if ( terminal && - terminal.failed > 0 && - (terminal.importedMessages > 0 || terminal.skipped > 0) + terminal.failedCount > 0 && + (terminal.importedMessageCount > 0 || terminal.skippedCount > 0) ) { finalStatus = "partial" - } else if (terminal && terminal.failed > 0) { + } else if (terminal && terminal.failedCount > 0) { finalStatus = "failed" } else { finalStatus = "succeeded" @@ -924,14 +829,10 @@ export const coexistMessengerSync = async ( { error, runId }, "[coexist] Messenger continuation enqueue failed — fallback to scheduler", ) - await db - .update(coexistSyncRunModel) - .set({ - status: "init", - lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + await coexistService.updateProgress({ + runId, + fields: { status: "init", lastHeartbeatAt: new Date() }, + }) } } } catch (error) { @@ -950,17 +851,16 @@ export const coexistMessengerSync = async ( }) } finally { if (finalStatus !== null) { - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { status: finalStatus, finishedAt: new Date(), lastHeartbeatAt: new Date(), currentStep: "done", currentError: errorRef.current ?? null, - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) } } diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts index 8ec3817c4c..6f5680ae82 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto" -import { db } from "@chatbotx.io/database/client" -import { whatsappCoexistStagingModel } from "@chatbotx.io/database/schema" +import { + integrationWhatsappRepository, + whatsappCoexistStagingRepository, +} from "@chatbotx.io/database/repositories" import { createId } from "@chatbotx.io/utils" import { buildCoexistFlushJobId, @@ -35,8 +37,8 @@ export const coexistWhatsappBuffer = async ( // Validate ownership BEFORE touching the staging table — otherwise a // spoofed or stale phoneNumberId would orphan rows that no flush can // ever drain (no integration row exists to gate them). - const integration = await db.query.integrationWhatsappModel.findFirst({ - where: { phoneNumberId }, + const integration = await integrationWhatsappRepository.findByPhoneNumberId({ + phoneNumberId, }) if (!integration) { @@ -49,15 +51,12 @@ export const coexistWhatsappBuffer = async ( // Idempotency: Meta retries webhook deliveries. (phoneNumberId, payloadHash) // is uniquely indexed, so duplicate deliveries collapse to one staging row. - await db - .insert(whatsappCoexistStagingModel) - .values({ - id: createId(), - phoneNumberId, - payload, - payloadHash: hashPayload(payload), - }) - .onConflictDoNothing() + await whatsappCoexistStagingRepository.stagePayload({ + id: createId(), + phoneNumberId, + payload, + payloadHash: hashPayload(payload), + }) // Only enqueue flush jobs when coexistEnabled — staging rows are always // persisted (to avoid data loss before the user enables coexist), but the diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts index 6c40771a2c..e645837c12 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts @@ -171,7 +171,7 @@ export const loadFlushContext = async ( // Claim FIRST — avoids wasting the inbox lookup if another worker owns this // run. The claimed row carries the resume counters AND the fresh ownership // token, so no second read is needed. - const run = await coexistService.claimRun({ + const run = await coexistService.claimRunWithNewToken({ runId, fromStatuses: LIVE_RUN_STATUSES, }) diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts index eff4c3bc6b..3d5464dfef 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts @@ -404,7 +404,7 @@ const resolveFinalStatus = async ( /** * Hands the run back before the continuation is queued. * - * `claimRun` refuses a run that is `running` with a heartbeat under 10 minutes + * `claimRunWithNewToken` refuses a run that is `running` with a heartbeat under 10 minutes * old — that is what stops two workers driving one run. A continuation * enqueued while this worker still holds the claim therefore loses its own * claim and abandons, so the chunk chain has to release ownership first: back @@ -577,7 +577,7 @@ const logChunkComplete = ( * popup. Idempotent: safe to re-run as more history arrives over the ~24h * window Meta uses to push it. * - * EXCLUSIVE OWNERSHIP. `claimRun` mints a `claimToken` on the run; every write + * EXCLUSIVE OWNERSHIP. `claimRunWithNewToken` mints a `claimToken` on the run; every write * this handler makes afterwards is conditional on BOTH `status = 'running'` and * that token. A write that reports 0 rows therefore covers both ways the run * can move out from under us — a `disconnect`/`disable`/workspace teardown that diff --git a/apps/worker/src/integration/handlers/contact/update-avatar.ts b/apps/worker/src/integration/handlers/contact/update-avatar.ts index 1d262a4117..5310bacd77 100644 --- a/apps/worker/src/integration/handlers/contact/update-avatar.ts +++ b/apps/worker/src/integration/handlers/contact/update-avatar.ts @@ -1,5 +1,4 @@ -import { and, db, eq, isNull } from "@chatbotx.io/database/client" -import { contactModel } from "@chatbotx.io/database/schema" +import { contactInboxService, contactService } from "@chatbotx.io/business" import type { IntegrationJobUpdateContactAvatar } from "@chatbotx.io/worker-config" import { logger } from "../../../lib/logger" import { @@ -21,7 +20,7 @@ export const updateContactAvatar = async ( ): Promise => { const { workspaceId, contactInboxId, sourceId } = data - const contactInbox = await db.query.contactInboxModel.findFirst({ + const contactInbox = await contactInboxService.findByUncached({ where: { id: contactInboxId }, }) if (!contactInbox) { @@ -29,9 +28,9 @@ export const updateContactAvatar = async ( return } - const contact = await db.query.contactModel.findFirst({ - where: { id: contactInbox.contactId }, - columns: { id: true, avatar: true }, + const contact = await contactService.findById({ + workspaceId, + id: contactInbox.contactId, }) if (!contact) { logger.warn( @@ -73,8 +72,9 @@ export const updateContactAvatar = async ( return } - await db - .update(contactModel) - .set({ avatar, updatedAt: new Date() }) - .where(and(eq(contactModel.id, contact.id), isNull(contactModel.avatar))) + await contactService.setAvatarIfEmpty({ + workspaceId, + contactId: contact.id, + avatar, + }) } diff --git a/apps/worker/src/integration/handlers/generate-text-agent/index.ts b/apps/worker/src/integration/handlers/generate-text-agent/index.ts index 85007d1040..ab4fe1be92 100644 --- a/apps/worker/src/integration/handlers/generate-text-agent/index.ts +++ b/apps/worker/src/integration/handlers/generate-text-agent/index.ts @@ -1,7 +1,7 @@ import { aiTimeouts } from "@chatbotx.io/ai" import { aiContextService } from "@chatbotx.io/ai/server" +import { aiAgentService } from "@chatbotx.io/business" import { logProviderError } from "@chatbotx.io/business/error-log" -import { db } from "@chatbotx.io/database/client" import { isMessageStorageError } from "@chatbotx.io/database/errors" import { type AIAgentModelConfig, @@ -30,7 +30,7 @@ export async function handleAIGenerateTextAgent({ const timeoutId = setTimeout(() => controller.abort(), aiTimeouts.aiTotal) try { - const aiAgent = await db.query.aiAgentModel.findFirst({ + const aiAgent = await aiAgentService.findBy({ where: { id: step.aiAgentId, workspaceId: conversation.workspaceId, diff --git a/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts b/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts index 34db4554d4..b02706537c 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" import { channelTypes } from "@chatbotx.io/database/partials" import { messengerWebhookEventSchema } from "@chatbotx.io/integration-messenger/schema" import type { Channel } from "../types" @@ -12,8 +12,8 @@ import type { Channel } from "../types" */ export const messengerChannel: Channel = { async loadContext(pageId) { - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { pageId }, + const integration = await messengerIntegrationService.findByPageIdUnscoped({ + pageId, }) if (!integration?.syncTagEnabledAt) { return null diff --git a/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts b/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts index 59c3103ee0..11d4948afa 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { zaloIntegrationService } from "@chatbotx.io/business" import { channelTypes } from "@chatbotx.io/database/partials" import { z } from "zod" import type { Channel } from "../types" @@ -22,9 +22,7 @@ const zaloTagEventSchema = z.object({ export const zaloChannel: Channel = { async loadContext(oaId) { - const integration = await db.query.integrationZaloModel.findFirst({ - where: { oaId }, - }) + const integration = await zaloIntegrationService.findByOaId({ oaId }) if (!integration?.syncTagEnabledAt) { return null } diff --git a/apps/worker/src/integration/handlers/inbox_labels/sync.ts b/apps/worker/src/integration/handlers/inbox_labels/sync.ts index 675014b660..e21f4de6f8 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/sync.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/sync.ts @@ -1,13 +1,6 @@ -import { tagSyncService } from "@chatbotx.io/business" -import { and, db, eq, inArray, isNull } from "@chatbotx.io/database/client" -import { - contactsToTagsModel, - contactToTagChannelModel, - tagChannelModel, - tagModel, -} from "@chatbotx.io/database/schema" +import { tagService, tagSyncService } from "@chatbotx.io/business" +import { contactInboxRepository } from "@chatbotx.io/database/repositories" import { emitTagApplied, emitTagRemoved } from "@chatbotx.io/events" -import { createId } from "@chatbotx.io/utils" import { logger } from "../../../lib/logger" import type { LabelContext, LabelEvent } from "./types" @@ -50,28 +43,17 @@ async function assignLabel( // Link the workspace tag to the contacts; capture the newly-linked ones so we // emit "tag applied" exactly once per new pair (same as add-contact-tag). - const linked = await db - .insert(contactsToTagsModel) - .values( - inboxes.map((inbox) => ({ - contactId: inbox.contactId, - tagId: mapping.tagId, - })), - ) - .onConflictDoNothing() - .returning({ contactId: contactsToTagsModel.contactId }) + const linked = await tagService.linkTagToContactsReturningNewUnscoped({ + tagId: mapping.tagId, + contactIds: inboxes.map((inbox) => inbox.contactId), + }) // Record the per-channel assignment (used for reconciliation / detach). - await db - .insert(contactToTagChannelModel) - .values( - inboxes.map((inbox) => ({ - tagId: mapping.tagId, - tagChannelId: mapping.tagChannelId, - contactInboxId: inbox.id, - })), - ) - .onConflictDoNothing() + await tagService.recordTagChannelAssignmentsUnscoped({ + tagId: mapping.tagId, + tagChannelId: mapping.tagChannelId, + contactInboxIds: inboxes.map((inbox) => inbox.id), + }) // Per-contact contactInboxId map, keyed off the same `inboxes` list used to // build the insert above — each newly-linked contact attributes to the @@ -111,26 +93,17 @@ async function unassignLabel( } // Remove the per-channel assignment record. - await db.delete(contactToTagChannelModel).where( - and( - eq(contactToTagChannelModel.tagChannelId, tagChannel.id), - inArray( - contactToTagChannelModel.contactInboxId, - inboxes.map((inbox) => inbox.id), - ), - ), - ) + await tagService.deleteTagChannelAssignmentsUnscoped({ + tagChannelId: tagChannel.id, + contactInboxIds: inboxes.map((inbox) => inbox.id), + }) // Remove the workspace tag from those contacts — same as remove-contact-tag. const contactIds = inboxes.map((inbox) => inbox.contactId) - await db - .delete(contactsToTagsModel) - .where( - and( - eq(contactsToTagsModel.tagId, tagChannel.tagId), - inArray(contactsToTagsModel.contactId, contactIds), - ), - ) + await tagService.detachTagFromContactsUnscoped({ + tagId: tagChannel.tagId, + contactIds, + }) await emitForContacts( ctx.workspaceId, @@ -188,21 +161,18 @@ async function removeLabel( // ── DB helpers ────────────────────────────────────────── function findInboxes(inboxId: string, sourceIds: string[]) { - return db.query.contactInboxModel.findMany({ - where: { inboxId, sourceId: { in: sourceIds } }, - columns: { id: true, contactId: true }, + return contactInboxRepository.listIdsByInboxAndSourceIds({ + inboxId, + sourceIds, }) } function findTagChannel(ctx: LabelContext, externalLabelId: string) { - return db.query.tagChannelModel.findFirst({ - where: { - workspaceId: ctx.workspaceId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - externalLabelId, - }, - columns: { id: true, tagId: true }, + return tagService.findTagChannel({ + workspaceId: ctx.workspaceId, + channelType: ctx.channelType, + integrationId: ctx.integrationId, + externalLabelId, }) } @@ -220,85 +190,20 @@ async function ensureTagChannel( return // cannot create a tag without a name } - const tagId = await ensureTag(ctx.workspaceId, name) + const tagId = await tagService.ensureTagByName({ + workspaceId: ctx.workspaceId, + name, + }) if (!tagId) { return } - const tagChannelId = await ensureChannel(ctx, tagId, externalLabelId) - return tagChannelId ? { tagId, tagChannelId } : undefined -} - -async function ensureTag( - workspaceId: string, - name: string, -): Promise { - const where = { workspaceId, name, deletedAt: { isNull: true as const } } - - const found = await db.query.tagModel.findFirst({ - where, - columns: { id: true }, - }) - if (found) { - return found.id - } - - const [created] = await db - .insert(tagModel) - .values({ id: createId(), workspaceId, name }) - .onConflictDoNothing({ - // Tag_workspaceId_name_key is a partial unique index (deletedAt IS NULL). - target: [tagModel.workspaceId, tagModel.name], - where: isNull(tagModel.deletedAt), - }) - .returning({ id: tagModel.id }) - if (created) { - return created.id - } - - // Lost a race against a concurrent insert — read the winner back. - const retry = await db.query.tagModel.findFirst({ - where, - columns: { id: true }, - }) - return retry?.id -} - -async function ensureChannel( - ctx: LabelContext, - tagId: string, - externalLabelId: string, -): Promise { - const [created] = await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId: ctx.workspaceId, - tagId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - externalLabelId, - }) - .onConflictDoNothing({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - }) - .returning({ id: tagChannelModel.id }) - if (created) { - return created.id - } - - const retry = await db.query.tagChannelModel.findFirst({ - where: { - tagId, - workspaceId: ctx.workspaceId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - }, - columns: { id: true }, + const tagChannelId = await tagService.ensureTagChannel({ + workspaceId: ctx.workspaceId, + tagId, + channelType: ctx.channelType, + integrationId: ctx.integrationId, + externalLabelId, }) - return retry?.id + return tagChannelId ? { tagId, tagChannelId } : undefined } diff --git a/apps/worker/src/integration/handlers/message-status.ts b/apps/worker/src/integration/handlers/message-status.ts index e26444eeed..324e880ce0 100644 --- a/apps/worker/src/integration/handlers/message-status.ts +++ b/apps/worker/src/integration/handlers/message-status.ts @@ -1,7 +1,7 @@ import { buildContext, conversationService } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" import { + contactInboxRepository, createMessageRepository, getSafeSinceTime, } from "@chatbotx.io/database/repositories" @@ -31,13 +31,7 @@ type StatusContactInboxWhere = { inboxId: string } & ( ) const findStatusContactInbox = (where: StatusContactInboxWhere) => - db.query.contactInboxModel.findFirst({ - where, - with: { - conversation: true, - contact: true, - }, - }) + contactInboxRepository.findWithConversationAndContact({ where }) /** * Resolves the ContactInbox a delivery/read status belongs to. The status diff --git a/apps/worker/src/integration/handlers/messenger-template-handler.ts b/apps/worker/src/integration/handlers/messenger-template-handler.ts index 3dc74eaebb..ccaa066575 100644 --- a/apps/worker/src/integration/handlers/messenger-template-handler.ts +++ b/apps/worker/src/integration/handlers/messenger-template-handler.ts @@ -1,4 +1,8 @@ -import { db } from "@chatbotx.io/database/client" +import { + inboxService, + messengerMessageTemplateService, +} from "@chatbotx.io/business" +import type { MessengerMessageTemplateModel } from "@chatbotx.io/database/types" import type { MessengerTemplateParams } from "@chatbotx.io/flow-config" import { contactVariableService, @@ -45,19 +49,10 @@ export async function replaceMessengerTemplateVariables(props: { return replacedParams } -// `typeof db.query.inboxModel.findFirst` alone (no call) resolves to the -// no-`with` overload, which drops `integrationMessenger` from the inferred -// return type — wrapping the actual call (with its `with` config) in a -// function lets `ReturnType` capture the relation instead. -function queryInboxWithIntegrationMessenger(inboxId: string) { - return db.query.inboxModel.findFirst({ - where: { id: inboxId }, - with: { integrationMessenger: true }, - }) -} - type InboxWithIntegrationMessenger = NonNullable< - Awaited> + Awaited< + ReturnType + > > export type ValidatedMessengerTemplate = { @@ -71,9 +66,7 @@ export type ValidatedMessengerTemplate = { InboxWithIntegrationMessenger["integrationMessenger"] > } - template: NonNullable< - Awaited> - > + template: MessengerMessageTemplateModel } // Accepts templateId string — returns fetched entities so caller avoids re-querying. @@ -82,19 +75,20 @@ export async function validateMessengerTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await queryInboxWithIntegrationMessenger(inboxId) + const inbox = await inboxService.findWithIntegrationMessengerByIdUnscoped({ + id: inboxId, + }) if (!inbox?.integrationMessenger) { return null } - const template = await db.query.messengerMessageTemplateModel.findFirst({ - where: { + const template = + await messengerMessageTemplateService.findApprovedByIdForIntegration({ id: templateId, integrationMessengerId: inbox.integrationMessenger.id, - status: "APPROVED", - }, - }) + workspaceId: inbox.workspaceId, + }) if (!template) { return null diff --git a/apps/worker/src/integration/handlers/received-message.ts b/apps/worker/src/integration/handlers/received-message.ts index 102c2543c4..cee3678bbe 100644 --- a/apps/worker/src/integration/handlers/received-message.ts +++ b/apps/worker/src/integration/handlers/received-message.ts @@ -20,20 +20,22 @@ import { finalizeContactProfile, normalizeLanguage, } from "@chatbotx.io/business/contact-locale" -import { db, eq, isUniqueViolationError } from "@chatbotx.io/database/client" +import { isUniqueViolationError } from "@chatbotx.io/database/client" import { type ChannelType, type ContactSource, contactSources, type IntegrationType, } from "@chatbotx.io/database/partials" -import { createMessageRepository } from "@chatbotx.io/database/repositories" +import { + contactInboxRepository, + createMessageRepository, +} from "@chatbotx.io/database/repositories" import { CONTACT_INBOX_SOURCE_ID_KEY, CONTACT_INBOX_SOURCE_USER_ID_KEY, contactInboxModel, contactModel, - conversationModel, } from "@chatbotx.io/database/schema" import type { ContactInboxModel, @@ -876,32 +878,17 @@ const persistNewMessageSideEffects = async (props: { contactLocation, } = props - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = await contactInboxService.updateTracking({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: inbox.workspaceId, - data: { - ...getMessageActivityTracking({ incomingMessage, message, storageUrl }), - ...contactInboxTracking, - }, - }) - - if (contactLocation) { - await contactService.update( - { workspaceId: inbox.workspaceId, id: contactInbox.contactId }, - { location: contactLocation }, - tx, - ) - } - - await tx - .update(conversationModel) - .set({ lastActivityAt: message.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation + const trackingInvalidation = await conversationService.recordInboundActivity({ + workspaceId: inbox.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + tracking: { + ...getMessageActivityTracking({ incomingMessage, message, storageUrl }), + ...contactInboxTracking, + }, + contactLocation, + at: message.createdAt, }) if (trackingInvalidation) { @@ -1209,9 +1196,8 @@ const resolveExistingContactInbox = async ({ incomingContact, }: ContactInboxResolverProps): Promise => await resolveWithSourceUserIdFallback(incomingContact, (where) => - db.query.contactInboxModel.findFirst({ + contactInboxRepository.findWithContact({ where: { inboxId: inbox.id, channel: inbox.channel, ...where }, - with: { contact: true }, }), ) diff --git a/apps/worker/src/integration/handlers/ref.ts b/apps/worker/src/integration/handlers/ref.ts index 333a2d6b8c..7cd5620be7 100644 --- a/apps/worker/src/integration/handlers/ref.ts +++ b/apps/worker/src/integration/handlers/ref.ts @@ -315,58 +315,4 @@ async function handleReflink(props: { contactInboxId: contactInbox.id, }) } - - // Support additional custom fields - - // // Trying to find reflink by custom field - // const refParts = ref.split("--").map((part) => part.trim()) - // if (!refParts[0]) { - // logger.warn(`Invalid ref: ${ref}`) - // return - // } - - // // Save data from custom field - // let customFieldId: string | null = null - // let customField: CustomFieldModel | null | undefined = null - // for (let i = 0; i < refParts.length; i++) { - // if (i === 0) { - // customFieldId = reflink.customFieldId ?? "" - // } else if (i % 2 === 0) { - // customFieldId = refParts[i] - // } - - // if (i % 2 === 0) { - // if (customFieldId) { - // customField = await db.query.customFieldModel.findFirst({ - // where: { - // workspaceId: conversation.workspaceId, - // id: customFieldId, - // }, - // }) - // } else { - // customField = null - // } - // } - - // // Trying to find custom field by name, then update contact custom field - // if (i % 2 === 1 && refParts[i] && customField) { - // await db - // .insert(contactCustomFieldModel) - // .values({ - // id: createId(), - // contactId: conversation.contactId, - // customFieldId: customField.id, - // value: refParts[i], - // }) - // .onConflictDoUpdate({ - // target: [ - // contactCustomFieldModel.contactId, - // contactCustomFieldModel.customFieldId, - // ], - // set: { - // value: refParts[i], - // }, - // }) - // } - // } } diff --git a/apps/worker/src/integration/handlers/send-flow-direct.ts b/apps/worker/src/integration/handlers/send-flow-direct.ts index 4f11331429..3712d0065e 100644 --- a/apps/worker/src/integration/handlers/send-flow-direct.ts +++ b/apps/worker/src/integration/handlers/send-flow-direct.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { contactInboxService, conversationService } from "@chatbotx.io/business" import type { MetadataPayload } from "@chatbotx.io/flow-config" import { runFlowNode } from "./flow" @@ -15,21 +15,17 @@ export async function sendFlowDirect( ): Promise { const { flowExecutionKey, flowId, workspaceId, contactId, metadata } = params - const conversation = await db.query.conversationModel.findFirst({ - where: { - contactId, - workspaceId, - }, + const conversation = await conversationService.findBy({ + where: { contactId, workspaceId }, }) if (!conversation) { throw new Error(`Conversation not found for contact ${contactId}`) } - const allContactInboxes = await db.query.contactInboxModel.findMany({ - where: { - contactId, - }, + const allContactInboxes = await contactInboxService.listByContactId({ + workspaceId, + contactId, }) await Promise.all( diff --git a/apps/worker/src/integration/handlers/sequence-flow.ts b/apps/worker/src/integration/handlers/sequence-flow.ts index b27a05c7d5..a6bde80681 100644 --- a/apps/worker/src/integration/handlers/sequence-flow.ts +++ b/apps/worker/src/integration/handlers/sequence-flow.ts @@ -1,5 +1,4 @@ -import { and, db, eq } from "@chatbotx.io/database/client" -import { sequenceDispatchModel } from "@chatbotx.io/database/schema" +import { contactSequenceService } from "@chatbotx.io/business/contact-sequence" import { sequenceConnections } from "@chatbotx.io/redis" import { SchedulerClient } from "@chatbotx.io/scheduler" import { advanceEnrollment } from "@chatbotx.io/sequence-scheduler" @@ -24,12 +23,9 @@ async function getSchedulerClient(): Promise { } async function fetchDispatch(dispatchId: string, workspaceId: string) { - return await db.query.sequenceDispatchModel.findFirst({ - where: { - id: dispatchId, - workspaceId, - status: "running", - }, + return await contactSequenceService.findRunningDispatch({ + dispatchId, + workspaceId, }) } @@ -38,20 +34,11 @@ async function markDispatchCompleted( workspaceId: string, sentAt: Date, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "completed", - completedAt: sentAt, - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchCompleted({ + dispatchId, + workspaceId, + sentAt, + }) } async function markDispatchCanceled( @@ -59,20 +46,11 @@ async function markDispatchCanceled( workspaceId: string, reason: string, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "canceled", - lastError: reason, - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchCanceled({ + dispatchId, + workspaceId, + reason, + }) } async function markDispatchFailed( @@ -80,21 +58,11 @@ async function markDispatchFailed( workspaceId: string, errorMessage: string, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "failed", - lastError: errorMessage, - failedAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchFailed({ + dispatchId, + workspaceId, + errorMessage, + }) } async function runSendSequenceFlow( diff --git a/apps/worker/src/integration/handlers/step-handlers.ts b/apps/worker/src/integration/handlers/step-handlers.ts index 32738359d0..189c6d5bf6 100644 --- a/apps/worker/src/integration/handlers/step-handlers.ts +++ b/apps/worker/src/integration/handlers/step-handlers.ts @@ -1,15 +1,12 @@ -import { conversationService } from "@chatbotx.io/business" import { - and, - db, - eq, - gte, - inArray, - or, - type SQL, - sql, -} from "@chatbotx.io/database/client" -import { contactModel, conversationModel } from "@chatbotx.io/database/schema" + contactInboxService, + contactService, + conversationService, + inboxTeamService, + workspaceMemberService, +} from "@chatbotx.io/business" +import { gte, type SQL } from "@chatbotx.io/database/client" +import { conversationModel } from "@chatbotx.io/database/schema" import { type ArchiveConversationStepSchema, type AssignConversationStepSchema, @@ -35,12 +32,13 @@ import type { ExecuteStepResult } from "./step" export async function stepBlockContact({ conversation, }: ExecuteStepProps) { - await db - .update(contactModel) - .set({ - blockedAt: new Date(), - }) - .where(eq(contactModel.id, conversation.contactId)) + // `block` → `update` adds `findByIdOrFail` + `emitContactInfoChangeEvents` + + // cache invalidation the raw write lacked — a deliberate behavior change; + // called out in the PR body. + await contactService.block({ + workspaceId: conversation.workspaceId, + id: conversation.contactId, + }) } export async function stepArchiveConversation({ @@ -82,24 +80,20 @@ export async function stepAssignConversation({ if (step.assignedId.startsWith("u_")) { const userId = step.assignedId.slice(2) - const workspaceMember = await db.query.workspaceMemberModel.findFirst({ - where: { - userId, - workspaceId: conversation.workspaceId, - }, + const isMember = await workspaceMemberService.isMember({ + workspaceId: conversation.workspaceId, + userId, }) - if (workspaceMember) { + if (isMember) { assignedUserId = userId } } else if (step.assignedId.startsWith("t_")) { const inboxTeamId = step.assignedId.slice(2) - const inboxTeam = await db.query.inboxTeamModel.findFirst({ - where: { - id: inboxTeamId, - workspaceId: conversation.workspaceId, - }, + const teamExists = await inboxTeamService.exists({ + workspaceId: conversation.workspaceId, + id: inboxTeamId, }) - if (inboxTeam) { + if (teamExists) { assignedInboxTeamId = inboxTeamId } } @@ -178,16 +172,9 @@ export async function stepAutoAssignConversation({ let requiredUsers: { userId: string }[] = [] if (userIds.length > 0) { - requiredUsers = await db.query.workspaceMemberModel.findMany({ - where: { - workspaceId: conversation.workspaceId, - userId: { - in: userIds, - }, - }, - columns: { - userId: true, - }, + requiredUsers = await workspaceMemberService.listExistingUserIds({ + workspaceId: conversation.workspaceId, + userIds, }) for (const u of requiredUsers) { allocation[`u_${u.userId}`] = { @@ -200,16 +187,9 @@ export async function stepAutoAssignConversation({ let requiredInboxTeams: { id: string }[] = [] if (inboxTeamIds.length > 0) { - requiredInboxTeams = await db.query.inboxTeamModel.findMany({ - where: { - workspaceId: conversation.workspaceId, - id: { - in: inboxTeamIds, - }, - }, - columns: { - id: true, - }, + requiredInboxTeams = await inboxTeamService.listExistingIds({ + workspaceId: conversation.workspaceId, + ids: inboxTeamIds, }) for (const t of requiredInboxTeams) { allocation[`t_${t.id}`] = { @@ -228,34 +208,11 @@ export async function stepAutoAssignConversation({ } } - const conversationCount = await db - .select({ - assignedUserId: conversationModel.assignedUserId, - assignedInboxTeamId: conversationModel.assignedInboxTeamId, - conversationsCount: sql`cast(count(${conversationModel.id}) as int)`, - }) - .from(conversationModel) - .groupBy( - conversationModel.assignedUserId, - conversationModel.assignedInboxTeamId, - ) - .where( - and( - ...filterConversationConditions, - and( - or( - inArray( - conversationModel.assignedUserId, - requiredUsers.map((r) => r.userId), - ), - inArray( - conversationModel.assignedInboxTeamId, - requiredInboxTeams.map((r) => r.id), - ), - ), - ), - ), - ) + const conversationCount = await conversationService.countByAssignee({ + filterConditions: filterConversationConditions, + userIds: requiredUsers.map((r) => r.userId), + inboxTeamIds: requiredInboxTeams.map((r) => r.id), + }) for (const cc of conversationCount) { if (cc.assignedUserId && allocation[`u_${cc.assignedUserId}`]) { allocation[`u_${cc.assignedUserId}`].count = cc.conversationsCount @@ -373,13 +330,9 @@ export const stepSendTyping = async ( const contactInbox = baseContactInbox || - (await db.query.contactInboxModel.findFirst({ - where: { - contactId: conversation.contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId: conversation.workspaceId, + contactId: conversation.contactId, })) if (!contactInbox) { diff --git a/apps/worker/src/integration/handlers/tool-handler.ts b/apps/worker/src/integration/handlers/tool-handler.ts index c8da7e9cca..b7274940ca 100644 --- a/apps/worker/src/integration/handlers/tool-handler.ts +++ b/apps/worker/src/integration/handlers/tool-handler.ts @@ -6,7 +6,6 @@ import { } from "@chatbotx.io/business" import { createSourceTimezoneResolver } from "@chatbotx.io/business/contact-custom-field" import { javascriptExecutionService } from "@chatbotx.io/business/javascript-execution" -import { db } from "@chatbotx.io/database/client" import { type CustomFieldType, type SystemFieldType, @@ -358,16 +357,9 @@ export async function getDataFromJSON({ // Custom-field outputs: unchanged behavior — batched existence lookup then // a single `setValues` call (transactional persistence + change events). if (customFieldMapping.length > 0) { - const validCustomFields = await db.query.customFieldModel.findMany({ - where: { - workspaceId, - id: { - in: customFieldMapping.map((entry) => entry.outputFieldId), - }, - }, - columns: { - id: true, - }, + const validCustomFields = await customFieldService.findManyByIds({ + workspaceId, + ids: customFieldMapping.map((entry) => entry.outputFieldId), }) const validCustomFieldIds = new Set(validCustomFields.map((v) => v.id)) diff --git a/apps/worker/src/integration/handlers/wa-template-handler.ts b/apps/worker/src/integration/handlers/wa-template-handler.ts index 587a84deeb..43252ce3ab 100644 --- a/apps/worker/src/integration/handlers/wa-template-handler.ts +++ b/apps/worker/src/integration/handlers/wa-template-handler.ts @@ -1,5 +1,11 @@ -import { db } from "@chatbotx.io/database/client" -import type { IntegrationWhatsappModel } from "@chatbotx.io/database/types" +import { + inboxService, + whatsappMessageTemplateService, +} from "@chatbotx.io/business" +import type { + IntegrationWhatsappModel, + WhatsappMessageTemplateModel, +} from "@chatbotx.io/database/types" import { extractTemplateParams, type SendWaTemplateMessageStepSchema, @@ -75,35 +81,33 @@ export async function replaceWhatsappTemplateVariables(props: { export type ValidatedWhatsappTemplate = { inbox: NonNullable< - Awaited> + Awaited< + ReturnType + > > & { integrationWhatsapp: IntegrationWhatsappModel } - template: NonNullable< - Awaited> - > + template: WhatsappMessageTemplateModel } export async function validateWhatsappTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await db.query.inboxModel.findFirst({ - where: { id: inboxId }, - with: { integrationWhatsapp: true }, + const inbox = await inboxService.findWithIntegrationWhatsappByIdUnscoped({ + id: inboxId, }) if (!inbox?.integrationWhatsapp) { return null } - const template = await db.query.whatsappMessageTemplateModel.findFirst({ - where: { + const template = + await whatsappMessageTemplateService.findApprovedByIdForIntegration({ id: templateId, integrationWhatsappId: inbox.integrationWhatsapp.id, - status: "APPROVED", - }, - }) + workspaceId: inbox.workspaceId, + }) if (!template) { return null diff --git a/apps/worker/src/integration/utils/contact.ts b/apps/worker/src/integration/utils/contact.ts index c968f2d751..d325633abb 100644 --- a/apps/worker/src/integration/utils/contact.ts +++ b/apps/worker/src/integration/utils/contact.ts @@ -1,6 +1,8 @@ -import { contactCustomFieldService } from "@chatbotx.io/business" +import { + contactCustomFieldService, + contactInboxService, +} from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db } from "@chatbotx.io/database/client" import type { ContactInboxModel } from "@chatbotx.io/database/types" import { getStoragePrefix, uploader } from "@chatbotx.io/filesystem" import { logger } from "../../lib/logger" @@ -15,13 +17,9 @@ export async function getIntegrationContext(props: { const contactInbox = baseContactInbox || - (await db.query.contactInboxModel.findFirst({ - where: { - contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId, + contactId, })) if (!contactInbox) { @@ -46,14 +44,7 @@ export async function readCustomFieldValue(props: { }): Promise { const { contactId, customFieldId } = props - const existing = await db.query.contactCustomFieldModel.findFirst({ - where: { - contactId, - customFieldId, - }, - }) - - return existing?.value ?? null + return await contactCustomFieldService.findValue({ contactId, customFieldId }) } export async function saveResultToCustomField(props: { diff --git a/apps/worker/src/lib/db.ts b/apps/worker/src/lib/db.ts index e90767d98d..4e7af209ad 100644 --- a/apps/worker/src/lib/db.ts +++ b/apps/worker/src/lib/db.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { flowService, flowVersionService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { contactInboxModel, conversationModel, @@ -56,26 +57,19 @@ export async function detectFlowVersion(props: { }> { let flowVersion: FlowVersionModel | null | undefined = null if (props.flowVersionId) { - flowVersion = await db.query.flowVersionModel.findFirst({ - where: { - id: props.flowVersionId, - workspaceId: props.workspaceId, - }, + flowVersion = await flowVersionService.findByIdForWorkspace({ + versionId: props.flowVersionId, + workspaceId: props.workspaceId, }) } else if (props.flowId) { - const flow = await db.query.flowModel.findFirst({ - where: { - id: props.flowId, - workspaceId: props.workspaceId, - active: true, - }, + const flow = await flowService.findActiveById({ + id: props.flowId, + workspaceId: props.workspaceId, }) if (flow?.currentVersionId) { - flowVersion = await db.query.flowVersionModel.findFirst({ - where: { - id: flow.currentVersionId, - workspaceId: props.workspaceId, - }, + flowVersion = await flowVersionService.findByIdForWorkspace({ + versionId: flow.currentVersionId, + workspaceId: props.workspaceId, }) } } diff --git a/apps/worker/src/lib/resolve-workspace-id.ts b/apps/worker/src/lib/resolve-workspace-id.ts index f88bb3d781..43ece38552 100644 --- a/apps/worker/src/lib/resolve-workspace-id.ts +++ b/apps/worker/src/lib/resolve-workspace-id.ts @@ -1,8 +1,10 @@ import { conversationService } from "@chatbotx.io/business" import { smartDelayService } from "@chatbotx.io/business/smart-delay" -import { db } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" -import { createAiWorkspaceScopeRepository } from "@chatbotx.io/database/repositories" +import { + createAiWorkspaceScopeRepository, + importRepository, +} from "@chatbotx.io/database/repositories" import { integrationService } from "../services/integrations" const aiWorkspaceScopeRepository = createAiWorkspaceScopeRepository() @@ -94,13 +96,7 @@ const recordIdResolvers: readonly RecordIdResolver[] = [ }, { field: "importId", - resolve: async (id) => - ( - await db.query.importModel.findFirst({ - where: { id }, - columns: { workspaceId: true }, - }) - )?.workspaceId, + resolve: (id) => importRepository.findWorkspaceId({ id }), }, ] diff --git a/apps/worker/src/services/integrations.ts b/apps/worker/src/services/integrations.ts index 47011f2ae0..8b585ea791 100644 --- a/apps/worker/src/services/integrations.ts +++ b/apps/worker/src/services/integrations.ts @@ -3,8 +3,9 @@ import { type IntegrationContext, workspaceService, } from "@chatbotx.io/business" -import { db, findOrFail, sql } from "@chatbotx.io/database/client" +import { findOrFail } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" +import { integrationLookupRepository } from "@chatbotx.io/database/repositories" import { inboxModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, @@ -131,31 +132,30 @@ export const integrationService = { throw new Error(`Unsupported integration: ${integrationType}`) } - const result = await db.execute<{ - id: string - auth: AuthValue - workspaceId: string - inboxId: string - }>( - sql`SELECT * FROM ${sql.identifier(modelName)} WHERE ${sql.identifier(columnName)} = ${integrationIdentifier} LIMIT 1`, - ) + const row = await integrationLookupRepository.findAuthByIdentifier({ + modelName, + columnName, + identifier: integrationIdentifier, + }) - if (!result.rows[0]) { + if (!row) { throw new IntegrationNotFoundError(integrationType, integrationIdentifier) } + const integrationRow = row as IntegrationRow & { workspaceId: string } + const workspace = await workspaceService.findById({ - id: result.rows[0].workspaceId, + id: integrationRow.workspaceId, }) const inbox = await findOrFail({ table: inboxModel, - where: { id: result.rows[0].inboxId }, + where: { id: integrationRow.inboxId }, message: "Inbox not found", }) return { - integrationRow: result.rows[0], + integrationRow, workspace, inbox, } @@ -201,15 +201,12 @@ export const integrationService = { ) } - const result = await db.execute<{ - id: string - auth: AuthValue - inboxId: string - }>( - sql`SELECT * FROM ${sql.identifier(integrationTable)} WHERE "inboxId" = ${contactInbox.inboxId} LIMIT 1`, - ) + const row = await integrationLookupRepository.findAuthByInboxId({ + modelName: integrationTable, + inboxId: contactInbox.inboxId, + }) - if (!result.rows[0]) { + if (!row) { throw new ChannelError( `Unable to find integration auth for channel: ${contactInbox.channel}`, ChannelErrorCategory.AUTH_FAILED, @@ -217,7 +214,7 @@ export const integrationService = { ) } - return result.rows[0] + return row as IntegrationRow }, } diff --git a/packages/business/__tests__/coexist-import-service.test.ts b/packages/business/__tests__/coexist-import-service.test.ts new file mode 100644 index 0000000000..c35ee2b8de --- /dev/null +++ b/packages/business/__tests__/coexist-import-service.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// `coexistImportService.resolveOrCreateContactLinks` is the Coexist historical +// import's phase-1 transaction, moved verbatim out of the worker handler +// (`apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts`) +// when direct `db.*` access was removed from `apps/worker`. +// +// The worker-level test can no longer see inside the transaction, so the +// invariants that used to be asserted there live here now: +// +// * The ContactInbox insert's `onConflictDoNothing()` stays UNTARGETED. A +// concurrent import can win EITHER identity index — (inboxId, sourceId) or +// the partial (inboxId, sourceUserId) — and a targeted clause would let the +// second one abort the whole batch. +// * Rows that lost the insert race are re-SELECTed by sourceId, and rows +// skipped on the partial scoped-user-id index are resolved through the +// winner owning that scoped id and then ALIASED back to the raced entry's +// own import key (message import is keyed by the entry's sourceId). +// * Pre-allocated Contact rows for raced entries are deleted, and +// `importedContacts` counts only the truly-new rows — not the raced ones. +// * Orphan conversations (existing ContactInbox + Contact, missing +// Conversation) are healed so no caller ever receives an empty +// conversationId. +// +// The schema module is stubbed with plain objects — importing the real schema +// opens a database connection through the sharding client. +// --------------------------------------------------------------------------- + +const { mockTransaction, mockCancelByInboxSource } = vi.hoisted(() => ({ + mockTransaction: vi.fn(), + mockCancelByInboxSource: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ __and: args })), + db: { transaction: mockTransaction }, + eq: vi.fn((left: unknown, right: unknown) => ({ __eq: [left, right] })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + __inArray: [column, values], + })), + or: vi.fn((...args: unknown[]) => ({ __or: args })), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + contactSources: { enum: { inboundMessage: "inboundMessage" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: { + id: "ci.id", + inboxId: "ci.inboxId", + sourceId: "ci.sourceId", + sourceUserId: "ci.sourceUserId", + contactId: "ci.contactId", + }, + contactModel: { id: "contact.id" }, + conversationModel: { id: "conv.id", contactId: "conv.contactId" }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: vi.fn(), + withCache: vi.fn(), +})) + +vi.mock("../src/message-cleanup/service", () => ({ + messageCleanupService: { cancelByInboxSource: mockCancelByInboxSource }, +})) + +let idSeq = 0 +vi.mock("@chatbotx.io/utils", () => ({ + createId: () => { + idSeq += 1 + return `id-${idSeq}` + }, +})) + +const { coexistImportService } = await import("../src/coexist-import/service") + +/** + * A recording transaction stub. `selectResults` is drained in call order — + * the method issues its SELECTs in a fixed sequence, so the queue models the + * database's answers turn by turn. + */ +const buildTx = (selectResults: unknown[][]) => { + const calls = { + conversationInsertValues: [] as unknown[][], + contactInboxInsertValues: [] as unknown[][], + contactInsertValues: [] as unknown[][], + deletedContacts: [] as unknown[], + onConflictDoNothingArgs: [] as unknown[], + } + const queue = [...selectResults] + const inboxReturning = { rows: [] as unknown[] } + + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(queue.shift() ?? [])), + })), + })) + + const insert = vi.fn((model: { id?: string }) => ({ + values: vi.fn((rows: unknown[]) => { + if (model.id === "contact.id") { + calls.contactInsertValues.push(rows) + return Promise.resolve(undefined) + } + if (model.id === "ci.id") { + calls.contactInboxInsertValues.push(rows) + return { + onConflictDoNothing: vi.fn((...args: unknown[]) => { + calls.onConflictDoNothingArgs.push(args) + return { + returning: vi.fn(() => Promise.resolve([...inboxReturning.rows])), + } + }), + } + } + calls.conversationInsertValues.push(rows) + return { + onConflictDoNothing: vi.fn(() => Promise.resolve(undefined)), + } + }), + })) + + const del = vi.fn(() => ({ + where: vi.fn((clause: unknown) => { + calls.deletedContacts.push(clause) + return Promise.resolve(undefined) + }), + })) + + return { + calls, + inboxReturning, + tx: { select, insert, delete: del }, + } +} + +const run = async ( + fixture: ReturnType, + input: Parameters[0], +) => { + mockTransaction.mockImplementation( + async (fn: (tx: unknown) => unknown) => await fn(fixture.tx), + ) + return await coexistImportService.resolveOrCreateContactLinks(input) +} + +beforeEach(() => { + vi.clearAllMocks() + idSeq = 0 + mockCancelByInboxSource.mockResolvedValue(undefined) +}) + +describe("coexistImportService.resolveOrCreateContactLinks", () => { + test("the ContactInbox insert's onConflictDoNothing() stays UNTARGETED", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [{ id: "conv-1", contactId: "id-1" }], // conversations for inserted rows + ]) + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1", firstName: "A" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + expect(fixture.calls.onConflictDoNothingArgs).toHaveLength(1) + // A targeted clause would let a conflict on the OTHER identity index + // abort the whole batch, so the call must carry no arguments at all. + expect(fixture.calls.onConflictDoNothingArgs[0]).toEqual([]) + }) + + test("counts only truly-new rows and deletes the pre-allocated Contact of a raced entry", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [{ id: "ci-won", sourceId: "s2", contactId: "contact-won" }], // race winners by sourceId + [ + { id: "ci-1", contactId: "id-1" }, + { id: "ci-won", contactId: "contact-won" }, + ], // conversations for accepted contacts + ]) + // Only s1 inserted; s2 lost the race. + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([ + ["s1", { sourceId: "s1" }], + ["s2", { sourceId: "s2" }], + ]), + sourceIds: ["s1", "s2"], + sourceUserIds: [], + }) + + // 2 attempted - 1 raced = 1 truly new. + expect(result.importedContacts).toBe(1) + // The Contact row pre-allocated for the raced s2 is deleted. + expect(fixture.calls.deletedContacts).toHaveLength(1) + // Both source ids still resolve to a link, so message import can proceed. + expect(result.contactInboxIds.get("s1")?.contactId).toBe("id-1") + expect(result.contactInboxIds.get("s2")?.contactId).toBe("contact-won") + // The event fan-out covers everything resolved through the insert path. + expect( + result.newContactCreatedEvents.map((e) => e.sourceId).sort(), + ).toEqual(["s1", "s2"]) + }) + + test("aliases a scoped-user-id race winner back to the raced entry's own import key", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [], // no winner under s-new's own sourceId + // the row that already owns scoped user id "u-9", under a DIFFERENT sourceId + [ + { + id: "ci-owner", + sourceId: "s-owner", + sourceUserId: "u-9", + contactId: "contact-owner", + }, + ], + [{ id: "conv-owner", contactId: "contact-owner" }], // conversations + ]) + fixture.inboxReturning.rows = [] // the insert was skipped entirely + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s-new", { sourceId: "s-new", sourceUserId: "u-9" }]]), + sourceIds: ["s-new"], + sourceUserIds: ["u-9"], + }) + + // Resolved under the winner's own sourceId... + expect(result.contactInboxIds.get("s-owner")).toEqual({ + contactInboxId: "ci-owner", + contactId: "contact-owner", + conversationId: "conv-owner", + }) + // ...AND aliased to the raced entry's import key, so downstream message + // import keyed on "s-new" still finds its contact. + expect(result.contactInboxIds.get("s-new")).toEqual( + result.contactInboxIds.get("s-owner"), + ) + expect(result.importedContacts).toBe(0) + }) + + test("heals an orphan conversation so no link is returned with an empty conversationId", async () => { + const fixture = buildTx([ + // an existing ContactInbox with no Conversation + [ + { + id: "ci-1", + sourceId: "s1", + sourceUserId: null, + contactId: "contact-1", + }, + ], + [], // no conversations for that contact → orphan + [{ id: "conv-healed", contactId: "contact-1" }], // read back after the heal insert + ]) + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + expect(fixture.calls.conversationInsertValues).toHaveLength(1) + expect(result.contactInboxIds.get("s1")).toEqual({ + contactInboxId: "ci-1", + contactId: "contact-1", + conversationId: "conv-healed", + }) + // Nothing was newly imported — the row already existed. + expect(result.importedContacts).toBe(0) + expect(result.newContactCreatedEvents).toEqual([]) + }) + + test("cancels pending message cleanup for every resolved inbox identity", async () => { + const fixture = buildTx([[], [{ id: "conv-1", contactId: "id-1" }]]) + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + // Re-created contacts keep their history. + expect(mockCancelByInboxSource).toHaveBeenCalledWith({ + inboxId: "inbox-1", + sourceIds: ["s1"], + tx: fixture.tx, + }) + }) +}) diff --git a/packages/business/__tests__/coexist.service.test.ts b/packages/business/__tests__/coexist.service.test.ts index fcd0ecb631..2d0769086c 100644 --- a/packages/business/__tests__/coexist.service.test.ts +++ b/packages/business/__tests__/coexist.service.test.ts @@ -17,7 +17,7 @@ vi.mock("@chatbotx.io/database/client", () => ({ vi.mock("@chatbotx.io/database/repositories", () => ({ coexistSyncRunRepository: { - claimRun: vi.fn(), + claimRunWithNewToken: vi.fn(), createRun: mocks.createRun, findIntegrationForCoexist: mocks.findIntegrationForCoexist, findLiveRun: mocks.findLiveRun, diff --git a/packages/business/__tests__/contact-service-flow-flags.test.ts b/packages/business/__tests__/contact-service-flow-flags.test.ts new file mode 100644 index 0000000000..772f5e75f3 --- /dev/null +++ b/packages/business/__tests__/contact-service-flow-flags.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// The three conditional/flag writes lifted out of the worker flow-step +// handlers (`apps/worker/src/integration/handlers/contact.ts` and +// `contact/update-avatar.ts`) when direct `db.*` access was removed from +// `apps/worker`. +// +// Two of them fold their eligibility check into the UPDATE's own WHERE +// clause. Those `isNull(...)` predicates are TOCTOU guards, not cosmetics: +// +// * `subscribeBroadcastIfUnsubscribed` must not overwrite an existing +// `broadcastSubscribedAt`, or a re-run of the flow step would reset the +// original subscription timestamp. +// * `setAvatarIfEmpty` must not overwrite an avatar a concurrent profile +// refresh already wrote. +// +// A read-then-write refactor would reintroduce both races, so the tests below +// assert the predicate is present in the WHERE and that no read precedes the +// write. `setFlowFlags` deliberately has NO conditional guard but DOES +// invalidate the contact cache (the raw worker write did not — a deliberate +// fix carried by the refactor). +// +// The schema module is stubbed with plain objects rather than +// `importOriginal`-ed: importing the real schema opens a database connection +// through the sharding client. +// --------------------------------------------------------------------------- + +const { mockDbUpdate } = vi.hoisted(() => ({ + mockDbUpdate: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ __and: args })), + db: { update: mockDbUpdate }, + eq: vi.fn((left: unknown, right: unknown) => ({ __eq: [left, right] })), + findOrFail: vi.fn(), + inArray: vi.fn(), + isNull: vi.fn((column: unknown) => ({ __isNull: column })), + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + __sql: [[...strings], values], + })), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: { id: "ci.id", contactId: "ci.contactId" }, + contactModel: { + id: "contact.id", + workspaceId: "contact.workspaceId", + avatar: "contact.avatar", + broadcastSubscribedAt: "contact.broadcastSubscribedAt", + emailOptIn: "contact.emailOptIn", + emailVerified: "contact.emailVerified", + }, + conversationModel: { id: "conv.id", contactId: "conv.contactId" }, + inboxModel: { id: "inbox.id" }, +})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactWhere: vi.fn(), + contactFilterHasPredicate: vi.fn(), +})) + +vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn() })) +vi.mock("@chatbotx.io/events", () => ({ + emitContactCreated: vi.fn(), + emitContactInfoUpdated: vi.fn(), +})) +vi.mock("@chatbotx.io/filesystem", () => ({ uploadFileFromUrl: vi.fn() })) +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: vi.fn(), + withCache: vi.fn(), +})) +vi.mock("@chatbotx.io/analytics", () => ({ macAnalyticsService: {} })) +vi.mock("../src/quota-enforcement/service", () => ({ + quotaEnforcementService: {}, +})) +vi.mock("../src/user-quota/service", () => ({ userQuotaService: {} })) +vi.mock("../src/workspace/service", () => ({ workspaceService: {} })) +vi.mock("../src/workspace-usage/service", () => ({ workspaceUsageService: {} })) +vi.mock("../src/message-cleanup/service", () => ({ messageCleanupService: {} })) + +const { contactService } = await import("../src/contact/service") +const { isNull: isNullMock } = await import("@chatbotx.io/database/client") + +const buildUpdateClient = () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + return { set, update, where } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("contactService.subscribeBroadcastIfUnsubscribed", () => { + test("keeps the isNull(broadcastSubscribedAt) TOCTOU guard inside the UPDATE's WHERE", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.subscribeBroadcastIfUnsubscribed({ + workspaceId: "ws-1", + contactId: "contact-1", + }) + + expect(isNullMock).toHaveBeenCalledWith("contact.broadcastSubscribedAt") + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + { __isNull: "contact.broadcastSubscribedAt" }, + ], + }) + expect(client.set).toHaveBeenCalledWith({ + broadcastSubscribedAt: expect.any(Date), + }) + }) + + test("is a single conditional write — no read precedes it", async () => { + const client = buildUpdateClient() + const query = { contactModel: { findFirst: vi.fn() } } + const select = vi.fn() + + await contactService.subscribeBroadcastIfUnsubscribed( + { workspaceId: "ws-1", contactId: "contact-1" }, + { update: client.update, select, query } as never, + ) + + expect(client.update).toHaveBeenCalledTimes(1) + expect(select).not.toHaveBeenCalled() + expect(query.contactModel.findFirst).not.toHaveBeenCalled() + }) +}) + +describe("contactService.unsubscribeBroadcast", () => { + test("clears the timestamp unconditionally but stays workspace-scoped", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.unsubscribeBroadcast({ + workspaceId: "ws-1", + contactId: "contact-1", + }) + + expect(client.set).toHaveBeenCalledWith({ broadcastSubscribedAt: null }) + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + ], + }) + // No conditional guard here — unsubscribe is idempotent by definition. + expect(isNullMock).not.toHaveBeenCalled() + }) +}) + +describe("contactService.setAvatarIfEmpty", () => { + test("keeps the isNull(avatar) TOCTOU guard inside the UPDATE's WHERE", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.setAvatarIfEmpty({ + workspaceId: "ws-1", + contactId: "contact-1", + avatar: "https://cdn.example/a.png", + }) + + expect(isNullMock).toHaveBeenCalledWith("contact.avatar") + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + { __isNull: "contact.avatar" }, + ], + }) + expect(client.set).toHaveBeenCalledWith({ + avatar: "https://cdn.example/a.png", + updatedAt: expect.any(Date), + }) + }) + + test("is a single conditional write — no read precedes it", async () => { + const client = buildUpdateClient() + const query = { contactModel: { findFirst: vi.fn() } } + const select = vi.fn() + + await contactService.setAvatarIfEmpty( + { workspaceId: "ws-1", contactId: "contact-1", avatar: "a.png" }, + { update: client.update, select, query } as never, + ) + + expect(client.update).toHaveBeenCalledTimes(1) + expect(select).not.toHaveBeenCalled() + expect(query.contactModel.findFirst).not.toHaveBeenCalled() + }) +}) + +describe("contactService.setFlowFlags", () => { + test("writes the flags by id and invalidates the contact cache", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + const invalidateSpy = vi + .spyOn(contactService, "invalidate") + .mockResolvedValue(undefined) + + await contactService.setFlowFlags( + { workspaceId: "ws-1", id: "contact-1" }, + { emailVerified: true }, + ) + + expect(client.set).toHaveBeenCalledWith({ emailVerified: true }) + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + ], + }) + // The raw worker write skipped invalidation, leaving a stale cached + // contact behind; routing through the service fixes that. + expect(invalidateSpy).toHaveBeenCalledWith({ + workspaceId: "ws-1", + ids: ["contact-1"], + }) + }) + + test("skips the pre-read and the info-change emit that update() would do", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + vi.spyOn(contactService, "invalidate").mockResolvedValue(undefined) + const findByIdOrFailSpy = vi.spyOn(contactService, "findByIdOrFail") + + await contactService.setFlowFlags( + { workspaceId: "ws-1", id: "contact-1" }, + { emailOptIn: true }, + ) + + expect(findByIdOrFailSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/conversation-service-consume-challenge.test.ts b/packages/business/__tests__/conversation-service-consume-challenge.test.ts index 97ef2b7981..e52718d400 100644 --- a/packages/business/__tests__/conversation-service-consume-challenge.test.ts +++ b/packages/business/__tests__/conversation-service-consume-challenge.test.ts @@ -25,6 +25,15 @@ vi.mock("@chatbotx.io/redis", () => ({ createRedisConnection: vi.fn(() => ({ on: vi.fn() })), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn(), })) diff --git a/packages/business/__tests__/conversation-service.test.ts b/packages/business/__tests__/conversation-service.test.ts index 60e57a28b1..b82d0f1666 100644 --- a/packages/business/__tests__/conversation-service.test.ts +++ b/packages/business/__tests__/conversation-service.test.ts @@ -48,6 +48,15 @@ vi.mock("@chatbotx.io/redis", () => ({ createRedisConnection: vi.fn(() => ({ on: vi.fn() })), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + const { conversationService } = await import("../src/conversation/service") /** diff --git a/packages/business/__tests__/integration-inbox-lookup.test.ts b/packages/business/__tests__/integration-inbox-lookup.test.ts index c357e42d60..1253e58115 100644 --- a/packages/business/__tests__/integration-inbox-lookup.test.ts +++ b/packages/business/__tests__/integration-inbox-lookup.test.ts @@ -14,11 +14,27 @@ const findOrFailMock = vi.fn() vi.mock("@chatbotx.io/database/client", () => ({ findOrFail: findOrFailMock, + db: {}, + eq: vi.fn(), + and: vi.fn(), })) vi.mock("@chatbotx.io/database/schema", () => ({ integrationZaloModel: { __table: "IntegrationZalo" }, integrationTelegramModel: { __table: "IntegrationTelegram" }, + tagChannelModel: { __table: "TagChannel" }, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { zalo: "zalo", telegram: "telegram" } }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: vi.fn(), })) beforeEach(() => { diff --git a/packages/business/__tests__/messenger-integration.service.connect-page.test.ts b/packages/business/__tests__/messenger-integration.service.connect-page.test.ts index 18766d87e1..cc0f273dc5 100644 --- a/packages/business/__tests__/messenger-integration.service.connect-page.test.ts +++ b/packages/business/__tests__/messenger-integration.service.connect-page.test.ts @@ -39,6 +39,14 @@ vi.mock("@chatbotx.io/database/schema", () => ({ integrationMessengerModel: { pageId: "pageId", }, + tagChannelModel: { + channelType: "channelType", + integrationId: "integrationId", + }, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { messenger: "messenger" } }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/src/coexist-import/index.ts b/packages/business/src/coexist-import/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/coexist-import/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/coexist-import/service.ts b/packages/business/src/coexist-import/service.ts new file mode 100644 index 0000000000..ed58211b28 --- /dev/null +++ b/packages/business/src/coexist-import/service.ts @@ -0,0 +1,527 @@ +import { + and, + type DatabaseClient, + db, + eq, + inArray, + or, + type SQL, +} from "@chatbotx.io/database/client" +import { contactSources } from "@chatbotx.io/database/partials" +import { + contactInboxModel, + contactModel, + conversationModel, +} from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" +import { messageCleanupService } from "../message-cleanup/service" + +/** + * WHERE clause matching contact-inbox rows in an inbox by EITHER identity + * column, with the empty-list guard every caller needs (`inArray` must never + * receive an empty array; `or(single)` is a no-op wrapper). Duplicated from + * `contact-inbox/service.ts`'s `buildContactInboxIdentityWhere` — kept local + * here (not imported) because this service must not create a cross-domain + * import cycle with `contact-inbox`; keep the two in sync if either changes. + */ +const buildContactInboxIdentityWhere = (props: { + inboxId: string + sourceIds: string[] + sourceUserIds: string[] +}): SQL | undefined => { + const { inboxId, sourceIds, sourceUserIds } = props + const identityPredicates = [ + ...(sourceIds.length > 0 + ? [inArray(contactInboxModel.sourceId, sourceIds)] + : []), + ...(sourceUserIds.length > 0 + ? [inArray(contactInboxModel.sourceUserId, sourceUserIds)] + : []), + ] + return and(eq(contactInboxModel.inboxId, inboxId), or(...identityPredicates)) +} + +export type ContactImportLink = { + contactInboxId: string + contactId: string + conversationId: string +} + +type ContactInboxIdentityRow = { + id: string + sourceId: string + sourceUserId: string | null + contactId: string +} + +const rowsBySourceUserId = ( + rows: readonly T[], +): Map => + new Map( + rows.flatMap((row) => + row.sourceUserId === null ? [] : [[row.sourceUserId, row] as const], + ), + ) + +/** + * Resolves raced entries whose ContactInbox insert was skipped by the partial + * (inboxId, sourceUserId) unique index: finds the winner row owning each + * entry's scoped user id, keyed by the entry's own import sourceId so the + * caller can alias the import key to the winner's link. + */ +const resolveScopedIdRaceWinners = async (props: { + tx: DatabaseClient + inboxId: string + racedEntries: ReadonlyArray +}): Promise> => { + const { tx, inboxId, racedEntries } = props + if (racedEntries.length === 0) { + return new Map() + } + const winners = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + sourceUserId: contactInboxModel.sourceUserId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray( + contactInboxModel.sourceUserId, + racedEntries.map(([, scopedId]) => scopedId), + ), + ), + ) + const winnerByScopedId = rowsBySourceUserId(winners) + const aliases = new Map() + for (const [entrySourceId, scopedId] of racedEntries) { + const winner = winnerByScopedId.get(scopedId) + if (winner) { + aliases.set(entrySourceId, winner) + } + } + return aliases +} + +export type CoexistDedupContact = { + sourceId: string + phoneNumber?: string + phoneNumberId?: string + firstName?: string + lastName?: string + email?: string + avatar?: string + gender?: string + sourceUserId?: string + sourceUsername?: string +} + +export type ResolveOrCreateContactLinksInput = { + workspaceId: string + inboxId: string + inboxChannel: string + dedup: Map + sourceIds: string[] + sourceUserIds: string[] +} + +export type NewContactCreatedEvent = { + workspaceId: string + contactId: string + contactInboxId: string + sourceId: string + firstName?: string + phoneNumber?: string + email?: string + channel: string + source: string + createdAt: Date +} + +export type ResolveOrCreateContactLinksResult = { + importedContacts: number + contactInboxIds: Map + newContactCreatedEvents: NewContactCreatedEvent[] +} + +class CoexistImportService extends BaseService { + /** + * Phase 1 of Coexist historical sync, moved VERBATIM (word-diffed against + * the pre-refactor worker transaction) from + * `apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts`'s + * `bulkImportContacts` `db.transaction` body: resolve/insert + * ContactInbox + Contact + Conversation for a dedup'd batch, healing orphan + * conversations and reconciling scoped-user-id insert races. The caller + * (`bulkImportContacts`) owns dedup, post-commit event emission, and + * workspace-usage accounting — this method only owns the transaction. + */ + async resolveOrCreateContactLinks( + input: ResolveOrCreateContactLinksInput, + ): Promise { + const { + workspaceId, + inboxId, + inboxChannel, + dedup, + sourceIds, + sourceUserIds, + } = input + + const newContactCreatedEvents: NewContactCreatedEvent[] = [] + let importedContacts = 0 + const contactInboxIds = new Map() + + await db.transaction(async (tx) => { + // 1. Find existing ContactInbox rows — by sourceId or scoped user id. + const existingRows = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + sourceUserId: contactInboxModel.sourceUserId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + buildContactInboxIdentityWhere({ + inboxId, + sourceIds, + sourceUserIds, + }), + ) + + const resolved = new Map() + const existingContactIds = new Set() + + for (const row of existingRows) { + existingContactIds.add(row.contactId) + resolved.set(row.sourceId, { + contactInboxId: row.id, + contactId: row.contactId, + conversationId: "", + }) + } + + const existingBySourceUserId = rowsBySourceUserId(existingRows) + for (const [sourceId, entry] of dedup) { + if (resolved.has(sourceId) || !entry.sourceUserId) { + continue + } + const row = existingBySourceUserId.get(entry.sourceUserId) + if (!row) { + continue + } + existingContactIds.add(row.contactId) + resolved.set(sourceId, { + contactInboxId: row.id, + contactId: row.contactId, + conversationId: "", + }) + } + + // Resolve conversation ids for existing contacts. Heal orphans (existing + // ContactInbox + Contact but missing Conversation) by inserting one now, + // so downstream callers never receive an empty conversationId. + if (existingContactIds.size > 0) { + const conversations = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, [...existingContactIds])) + const convByContact = new Map( + conversations.map((c) => [c.contactId, c.id]), + ) + + const orphanContactIds = [...existingContactIds].filter( + (cid) => !convByContact.has(cid), + ) + if (orphanContactIds.length > 0) { + await tx + .insert(conversationModel) + .values( + orphanContactIds.map((cid) => ({ + id: createId(), + workspaceId, + contactId: cid, + })), + ) + .onConflictDoNothing() + const healed = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, orphanContactIds)) + for (const c of healed) { + convByContact.set(c.contactId, c.id) + } + } + + for (const link of resolved.values()) { + const cid = convByContact.get(link.contactId) + if (cid) { + link.conversationId = cid + } + } + } + + const newEntries = [...dedup.entries()].filter( + ([sourceId]) => !resolved.has(sourceId), + ) + const acceptedNew = newEntries + + // 2. Insert Contact + ContactInbox + Conversation for acceptedNew. + if (acceptedNew.length > 0) { + const contactRows = acceptedNew.map(([, entry]) => ({ + id: createId(), + workspaceId, + firstName: entry.firstName, + lastName: entry.lastName, + email: entry.email, + phoneNumber: entry.phoneNumber, + avatar: entry.avatar, + })) + + await tx.insert(contactModel).values(contactRows) + + const contactInboxRows = acceptedNew.map(([sourceId, entry], i) => ({ + id: createId(), + inboxId, + contactId: contactRows[i]?.id, + originalContactId: contactRows[i]?.id, + source: contactSources.enum.inboundMessage, + sourceId, + sourceUserId: entry.sourceUserId ?? null, + sourceUsername: entry.sourceUsername ?? null, + channel: inboxChannel, + createdAt: new Date(), + updatedAt: new Date(), + })) + + const conversationRows = acceptedNew.map((_entry, i) => ({ + id: createId(), + workspaceId, + contactId: contactRows[i]?.id, + })) + + // Targetless DO NOTHING: a concurrent import can win EITHER identity + // index — (inboxId, sourceId) or the partial (inboxId, sourceUserId) — + // and a targeted clause would let the second one abort the whole batch. + const insertedInboxes = await tx + .insert(contactInboxModel) + .values(contactInboxRows) + .onConflictDoNothing() + .returning({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + contactId: contactInboxModel.contactId, + }) + + const insertedSourceIds = new Set( + insertedInboxes.map((r) => r.sourceId), + ) + + // Race recovery — any acceptedNew sourceId not inserted lost to a + // concurrent insert; re-SELECT winners + delete pre-allocated orphans. + const racedSourceIds = acceptedNew + .map(([sourceId]) => sourceId) + .filter((s) => !insertedSourceIds.has(s)) + + // Maps a raced entry's import key to the winner row that claimed its + // scoped user id under a DIFFERENT sourceId — the final link mapping is + // keyed by row.sourceId, so these aliases are re-keyed at the end. + let scopedWinnerAliases = new Map() + + if (racedSourceIds.length > 0) { + const winners = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray(contactInboxModel.sourceId, racedSourceIds), + ), + ) + for (const w of winners) { + insertedInboxes.push(w) + insertedSourceIds.add(w.sourceId) + } + + // A raced row skipped on the scoped-user-id index has no winner under + // its own sourceId — resolve it through the row owning that scoped id. + scopedWinnerAliases = await resolveScopedIdRaceWinners({ + tx, + inboxId, + racedEntries: racedSourceIds.flatMap((sourceId) => { + if (insertedSourceIds.has(sourceId)) { + return [] + } + const scopedId = dedup.get(sourceId)?.sourceUserId + return scopedId ? [[sourceId, scopedId] as const] : [] + }), + }) + for (const winner of scopedWinnerAliases.values()) { + insertedInboxes.push({ + id: winner.id, + sourceId: winner.sourceId, + contactId: winner.contactId, + }) + } + + const racedSet = new Set(racedSourceIds) + const orphanIds: string[] = [] + for (let i = 0; i < acceptedNew.length; i++) { + const sourceId = acceptedNew[i]?.[0] + const contactId = contactRows[i]?.id + if (sourceId && contactId && racedSet.has(sourceId)) { + orphanIds.push(contactId) + } + } + if (orphanIds.length > 0) { + await tx + .delete(contactModel) + .where(inArray(contactModel.id, orphanIds)) + } + } + + // Re-created contacts keep their history: cancel any pending message + // cleanup recorded when contacts with these inbox identities were deleted. + await messageCleanupService.cancelByInboxSource({ + inboxId, + sourceIds: insertedInboxes.map((r) => r.sourceId), + tx, + }) + + const trulyNew = acceptedNew.length - racedSourceIds.length + importedContacts = trulyNew + + const racedSet2 = new Set(racedSourceIds) + const conversationsToInsert = conversationRows.filter( + (_row, i) => !racedSet2.has(acceptedNew[i]?.[0]), + ) + if (conversationsToInsert.length > 0) { + await tx + .insert(conversationModel) + .values(conversationsToInsert) + .onConflictDoNothing() + } + + // Resolve conversation ids for everything just inserted (or raced). + const acceptedContactIds = insertedInboxes.map((r) => r.contactId) + const newConversations = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, acceptedContactIds)) + const convByContactNew = new Map( + newConversations.map((c) => [c.contactId, c.id]), + ) + + for (const inboxRow of insertedInboxes) { + const convId = convByContactNew.get(inboxRow.contactId) + if (!convId) { + continue + } + resolved.set(inboxRow.sourceId, { + contactInboxId: inboxRow.id, + contactId: inboxRow.contactId, + conversationId: convId, + }) + + const entry = dedup.get(inboxRow.sourceId) + if (entry) { + newContactCreatedEvents.push({ + workspaceId, + contactId: inboxRow.contactId, + contactInboxId: inboxRow.id, + sourceId: inboxRow.sourceId, + firstName: entry.firstName, + phoneNumber: entry.phoneNumber, + email: entry.email, + channel: inboxChannel, + source: contactSources.enum.inboundMessage, + createdAt: new Date(), + }) + } + } + + // Scoped-id winners resolve under their own sourceId above; alias the + // raced entry's import key to the same link so downstream message + // imports keyed by the entry's sourceId still find their contact. + for (const [entrySourceId, winner] of scopedWinnerAliases) { + const link = resolved.get(winner.sourceId) + if (link) { + resolved.set(entrySourceId, link) + } + } + } + + for (const [sourceId, link] of resolved) { + contactInboxIds.set(sourceId, link) + } + }) + + return { importedContacts, contactInboxIds, newContactCreatedEvents } + } + + /** + * One-shot ContactInbox+Conversation resolution by `(inboxId, sourceId[])` + * — moved from `messenger-sync.ts`'s inline `leftJoin` query. Keep the + * `leftJoin` shape verbatim (a missing conversation yields `conversationId: + * null`, filtered out by the caller). + */ + async listContactLinksBySourceIds(props: { + inboxId: string + sourceIds: string[] + }): Promise< + Array<{ + sourceId: string + contactInboxId: string + contactId: string + conversationId: string | null + }> + > { + const { inboxId, sourceIds } = props + if (sourceIds.length === 0) { + return [] + } + const rows = await db + .select({ + sourceId: contactInboxModel.sourceId, + contactInboxId: contactInboxModel.id, + contactId: contactInboxModel.contactId, + conversationId: conversationModel.id, + }) + .from(contactInboxModel) + .leftJoin( + conversationModel, + eq(conversationModel.contactId, contactInboxModel.contactId), + ) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray(contactInboxModel.sourceId, sourceIds), + ), + ) + return rows.map((row) => ({ + sourceId: row.sourceId ?? "", + contactInboxId: row.contactInboxId, + contactId: row.contactId, + conversationId: row.conversationId, + })) + } +} + +export const coexistImportService = new CoexistImportService() diff --git a/packages/business/src/coexist/service.ts b/packages/business/src/coexist/service.ts index 02572c1dc6..a99a8b8151 100644 --- a/packages/business/src/coexist/service.ts +++ b/packages/business/src/coexist/service.ts @@ -173,11 +173,11 @@ class CoexistService extends BaseService { * WhatsApp flush passes `LIVE_RUN_STATUSES` so a run parked in `waiting` is * claimable too. */ - claimRun(input: { + claimRunWithNewToken(input: { runId: string fromStatuses?: CoexistRunStatus[] }): Promise { - return coexistSyncRunRepository.claimRun(input) + return coexistSyncRunRepository.claimRunWithNewToken(input) } findRunById(input: { runId: string }): Promise { @@ -307,6 +307,37 @@ class CoexistService extends BaseService { input, ) } + + findLastSyncedAt(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findLastSyncedAt(input) + } + + incrementProgress( + input: Parameters[0], + ): Promise { + return coexistSyncRunRepository.incrementProgress(input) + } + + findInitState(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findInitState(input) + } + + reclaimRunForRetry(input: { + runId: string + touchUpdatedAt: boolean + }): Promise { + return coexistSyncRunRepository.reclaimRunForRetry(input) + } + + findTerminalCounters(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findTerminalCounters(input) + } } export const coexistService = new CoexistService() diff --git a/packages/business/src/contact-sequence/service.ts b/packages/business/src/contact-sequence/service.ts index 8128862a93..c8cd7e01d8 100644 --- a/packages/business/src/contact-sequence/service.ts +++ b/packages/business/src/contact-sequence/service.ts @@ -20,6 +20,7 @@ import { enrollContactInSequence, enrollContactsInSequenceBulk, removeDispatchesFromSchedule, + sequenceDispatchUtils, } from "@chatbotx.io/sequence-scheduler" import { BaseService } from "../base.service" import { type ContactAccessScope, contactService } from "../contact/service" @@ -674,6 +675,80 @@ class ContactSequenceService extends BaseService { ): Promise { return await db.transaction(callback) } + + /** Already-enrolled guard for the "Subscribe to Sequence" flow step. */ + async isEnrolled(props: { + workspaceId: string + contactId: string + sequenceId: string + tx?: DrizzleClient + }): Promise { + const { workspaceId, contactId, sequenceId, tx = db } = props + const existing = await tx.query.contactsOnSequenceModel.findFirst({ + where: { contactId, sequenceId, workspaceId }, + columns: { id: true }, + }) + return Boolean(existing) + } + + /** First active step (order 0) — used to compute `nextRunAt` on enroll. */ + async findFirstActiveStep(props: { + sequenceId: string + tx?: DrizzleClient + }): Promise< + { id: string; delayDays: number; delayMinutes: number } | undefined + > { + const { sequenceId, tx = db } = props + return await tx.query.sequenceStepModel.findFirst({ + where: { sequenceId, order: 0, isActive: true }, + columns: { id: true, delayDays: true, delayMinutes: true }, + }) + } + + /** Sequence name for the `sequenceSubscribed` emit. */ + async findSequenceName(props: { + sequenceId: string + tx?: DrizzleClient + }): Promise { + const { sequenceId, tx = db } = props + const sequence = await tx.query.sequenceModel.findFirst({ + where: { id: sequenceId }, + columns: { name: true }, + }) + return sequence?.name + } + + /** Load a running dispatch for the sequence-flow worker handler. */ + findRunningDispatch(props: { dispatchId: string; workspaceId: string }) { + return sequenceDispatchUtils.findRunning({ dbClient: db, ...props }) + } + + /** Mark a dispatch completed — keeps the `status = 'running'` idempotency guard. */ + markDispatchCompleted(props: { + dispatchId: string + workspaceId: string + sentAt: Date + }): Promise { + return sequenceDispatchUtils.markCompleted({ dbClient: db, ...props }) + } + + /** Mark a dispatch canceled — keeps the `status = 'running'` idempotency guard. */ + markDispatchCanceled(props: { + dispatchId: string + workspaceId: string + reason: string + }): Promise { + return sequenceDispatchUtils.markCanceled({ dbClient: db, ...props }) + } + + /** Mark a dispatch failed — keeps the `status = 'running'` idempotency guard. */ + markDispatchFailed(props: { + dispatchId: string + workspaceId: string + errorMessage: string + }): Promise { + return sequenceDispatchUtils.markFailed({ dbClient: db, ...props }) + } } export const contactSequenceService = new ContactSequenceService() diff --git a/packages/business/src/contact/service.ts b/packages/business/src/contact/service.ts index f96086d432..b8ba341f01 100644 --- a/packages/business/src/contact/service.ts +++ b/packages/business/src/contact/service.ts @@ -891,6 +891,92 @@ class ContactService extends BaseService { .where(eq(contactModel.id, cid)) await invalidateCacheByTags([`contacts:${cid}`]) } + + /** + * Thin flow-step flag write (email verified / opt-in / opt-out). Skips the + * pre-read and `emitContactInfoChangeEvents` that `update()` performs — this + * is a hot flow-step path and those steps never emitted before — but DOES + * invalidate the contact cache, which the raw `db.update` this replaces did + * NOT do. That cache invalidation is a deliberate bug fix; call it out in + * the PR body. Do not route through `update()` (adds `findByIdOrFail` + + * `emitContactInfoChangeEvents` on this hot step). + */ + async setFlowFlags( + ctx: { workspaceId: string; id: string }, + data: Partial>, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(contactModel) + .set(data) + .where( + and( + eq(contactModel.id, ctx.id), + eq(contactModel.workspaceId, ctx.workspaceId), + ), + ) + await this.invalidate({ workspaceId: ctx.workspaceId, ids: [ctx.id] }) + } + + /** + * Conditional broadcast subscribe — the `isNull(broadcastSubscribedAt)` + * predicate is a TOCTOU guard and MUST stay in the WHERE clause (mirrors + * `updateIfProfileNameEmpty`). + */ + async subscribeBroadcastIfUnsubscribed( + props: { workspaceId: string; contactId: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId } = props + await tx + .update(contactModel) + .set({ broadcastSubscribedAt: new Date() }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + isNull(contactModel.broadcastSubscribedAt), + ), + ) + } + + /** Unconditional broadcast unsubscribe. Handler keeps `emitContactUnsubscribed`. */ + async unsubscribeBroadcast( + props: { workspaceId: string; contactId: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId } = props + await tx + .update(contactModel) + .set({ broadcastSubscribedAt: null }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + ), + ) + } + + /** + * Conditional avatar write — keeps `isNull(avatar)` in the WHERE clause (a + * TOCTOU guard, same pattern as `updateIfProfileNameEmpty`). + */ + async setAvatarIfEmpty( + props: { workspaceId: string; contactId: string; avatar: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId, avatar } = props + await tx + .update(contactModel) + .set({ avatar, updatedAt: new Date() }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + isNull(contactModel.avatar), + ), + ) + } } function richSystemFieldToContactData( diff --git a/packages/business/src/conversation/__tests__/conversation.service.test.ts b/packages/business/src/conversation/__tests__/conversation.service.test.ts index 4724baac36..15d7e54ea1 100644 --- a/packages/business/src/conversation/__tests__/conversation.service.test.ts +++ b/packages/business/src/conversation/__tests__/conversation.service.test.ts @@ -18,14 +18,37 @@ vi.mock("@chatbotx.io/database/client", () => ({ sql: vi.fn(), })) +// Plain object stubs only — importing the real schema opens a database +// connection through the sharding client. The extra models come from +// `contactService`, now in `conversationService`'s import chain. vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: {}, + workspaceUsageModel: {}, + userQuotaModel: {}, + questionnaireSubmissionModel: {}, + adsConversionEventModel: {}, + refLinkStatModel: {}, + contactsOnSequenceModel: {}, + contactsOnBroadcastsModel: {}, + contactsToTagsModel: {}, + contactModel: {}, conversationModel: {}, + inboxModel: {}, })) vi.mock("@chatbotx.io/redis", () => ({ withCache: vi.fn(), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn(), })) diff --git a/packages/business/src/conversation/service.ts b/packages/business/src/conversation/service.ts index 0b81f9f76e..6f339f8e15 100644 --- a/packages/business/src/conversation/service.ts +++ b/packages/business/src/conversation/service.ts @@ -4,6 +4,8 @@ import { db, eq, inArray, + or, + type SQL, sql, } from "@chatbotx.io/database/client" import { @@ -49,6 +51,11 @@ import { notificationQueue, } from "@chatbotx.io/worker-config" import { BaseService } from "../base.service" +import { contactService } from "../contact" +import type { + ContactInboxTrackingData, + ContactInboxTrackingInvalidation, +} from "../contact-inbox/service" import { contactInboxService } from "../contact-inbox/service" import { notFoundException } from "../errors" import { logger } from "../logger" @@ -1149,6 +1156,219 @@ class ConversationService extends BaseService { ] await this.invalidateCacheTags(tags) } + + /** + * Conversation + contact, for the hot send-flow-step path (do NOT use + * `findWithFullRelations` here — it fetches far more). Unscoped by `id` + * only — safe today because its sole caller (`send-flow-step.ts`) is the + * entry point that resolves the workspace *from* this conversation lookup, + * so no `workspaceId` exists yet to filter by. + */ + async findByIdWithContactUnscoped(props: { + id: string + tx?: DatabaseClient + }): Promise< + (ConversationModel & { contact: ContactModel | null }) | undefined + > { + const { id, tx = db } = props + return await tx.query.conversationModel.findFirst({ + where: { id }, + with: { contact: true }, + }) + } + + /** + * Inbound-message activity write — owns the transaction: contact-inbox + * tracking update, an optional contact location write, and the + * conversation's `lastActivityAt` advance (via `updateFlowStepState` + * instead of a raw `tx.update`). Moved from + * `received-message.ts`'s `persistNewMessageSideEffects`. + * + * NOTE: `updateFlowStepState`'s WHERE includes `workspaceId` — the raw + * worker version did not scope by `workspaceId` on this UPDATE. Safe here + * because the conversation is already loaded workspace-scoped upstream, + * but this is a deliberate behavior change — call it out in the PR body. + */ + async recordInboundActivity(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + tracking: ContactInboxTrackingData + contactLocation?: ContactModel["location"] | null + at: Date + }): Promise { + const { + workspaceId, + conversationId, + contactInboxId, + contactId, + tracking, + contactLocation, + at, + } = props + + return await db.transaction(async (tx) => { + const invalidation = await contactInboxService.updateTracking({ + tx, + contactInboxId, + contactId, + workspaceId, + data: tracking, + }) + + if (contactLocation) { + await contactService.update( + { workspaceId, id: contactId }, + { location: contactLocation }, + tx, + ) + } + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + }) + + return invalidation + }) + } + + /** + * Outbound flow-step send activity — owns the transaction: + * `recordOutboundMessageCreated` + `updateFlowStepState` (advances + * `currentStep`/`lastStep`/`lastActivityAt`). Moved from + * `chat/handlers/send-flow-step.ts`'s `sendFlowStep`. + */ + async recordOutboundFlowStep(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + at: Date + lastStep?: string | null + currentStep?: string | null + }): Promise { + const { + workspaceId, + conversationId, + contactInboxId, + contactId, + at, + lastStep, + currentStep, + } = props + + return await db.transaction(async (tx) => { + const invalidation = + await contactInboxService.recordOutboundMessageCreated({ + tx, + contactInboxId, + contactId, + workspaceId, + at, + }) + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + lastStep, + currentStep, + }) + + return invalidation + }) + } + + /** + * Outbound message activity (chat / template sends) — owns the + * transaction: `recordOutboundMessageCreated` + `updateFlowStepState` + * (`lastActivityAt` only). Shared by `sendChatMessage`, + * `send-messenger-template.ts`, and `send-whatsapp-template.ts` — write + * once, call from all three. + * + * NOTE: `updateFlowStepState`'s WHERE includes `workspaceId` — the raw + * worker version's `tx.update(conversationModel)` scoped only by `id`. + * Safe here (conversation is already workspace-scoped upstream) but a + * deliberate behavior change — call it out in the PR body. + */ + async recordOutboundMessageActivity(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + at: Date + }): Promise { + const { workspaceId, conversationId, contactInboxId, contactId, at } = props + + return await db.transaction(async (tx) => { + const invalidation = + await contactInboxService.recordOutboundMessageCreated({ + tx, + contactInboxId, + contactId, + workspaceId, + at, + }) + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + }) + + return invalidation + }) + } + + /** + * Per-assignee open-conversation counts for round-robin allocation + * (`step-handlers.ts`'s `stepAutoAssignConversation`). Accepts the raw + * `filterConditions` `SQL[]` built by the caller (e.g. the "last N hours" + * rule) rather than a semantic filter object — a documented fallback to + * avoid a larger move of `filterConversationConditions` construction. + */ + async countByAssignee(props: { + filterConditions: SQL[] + userIds: string[] + inboxTeamIds: string[] + tx?: DatabaseClient + }): Promise< + Array<{ + assignedUserId: string | null + assignedInboxTeamId: string | null + conversationsCount: number + }> + > { + const { filterConditions, userIds, inboxTeamIds, tx = db } = props + return await tx + .select({ + assignedUserId: conversationModel.assignedUserId, + assignedInboxTeamId: conversationModel.assignedInboxTeamId, + conversationsCount: sql`cast(count(${conversationModel.id}) as int)`, + }) + .from(conversationModel) + .groupBy( + conversationModel.assignedUserId, + conversationModel.assignedInboxTeamId, + ) + .where( + and( + ...filterConditions, + and( + or( + inArray(conversationModel.assignedUserId, userIds), + inArray(conversationModel.assignedInboxTeamId, inboxTeamIds), + ), + ), + ), + ) + } } export const conversationService = new ConversationService() diff --git a/packages/business/src/enterprise/inbox-team/service.ts b/packages/business/src/enterprise/inbox-team/service.ts index b81fc23262..ef12e7e5d9 100644 --- a/packages/business/src/enterprise/inbox-team/service.ts +++ b/packages/business/src/enterprise/inbox-team/service.ts @@ -214,6 +214,36 @@ class InboxTeamService extends BaseService { `inbox-teams:${props.workspaceId}`, ]) } + + /** Validate one assignee team id (flow-step assign). */ + async exists(props: { + workspaceId: string + id: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, tx = db } = props + const team = await tx.query.inboxTeamModel.findFirst({ + where: { id, workspaceId }, + columns: { id: true }, + }) + return Boolean(team) + } + + /** Bulk team validation for round-robin allocation. */ + async listExistingIds(props: { + workspaceId: string + ids: string[] + tx?: DatabaseClient + }): Promise<{ id: string }[]> { + const { workspaceId, ids, tx = db } = props + if (ids.length === 0) { + return [] + } + return await tx.query.inboxTeamModel.findMany({ + where: { workspaceId, id: { in: ids } }, + columns: { id: true }, + }) + } } export const inboxTeamService = new InboxTeamService() diff --git a/packages/business/src/flow-version/service.ts b/packages/business/src/flow-version/service.ts index 48cbef9092..8ed372f94a 100644 --- a/packages/business/src/flow-version/service.ts +++ b/packages/business/src/flow-version/service.ts @@ -285,6 +285,23 @@ class FlowVersionService extends BaseService { async invalidateList(flowId: string): Promise { await this.invalidateCacheTags(`flows:${flowId}:versions`) } + + /** + * Version by explicit id, scoped only to workspace — no `flowId`, no + * `isDraft` filter, and never throws. Matches the worker's + * `detectFlowVersion` exact where-clause; do NOT reuse `findById` (which + * requires `flowId`, forces `isDraft: false`, and throws). + */ + async findByIdForWorkspace(props: { + versionId: string + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { versionId, workspaceId, tx = db } = props + return await tx.query.flowVersionModel.findFirst({ + where: { id: versionId, workspaceId }, + }) + } } export const flowVersionService = new FlowVersionService() diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index a252b5eae2..c10ef08dc2 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -458,6 +458,36 @@ class FlowService extends BaseService { `deleted flow${flows.length > 1 ? "s" : ""} (${flows.map((flow) => `#${flow.id}`).join(", ")})`, ) } + + /** + * Active flow by id, scoped to workspace. Used by worker's + * `detectFlowVersion` to resolve the current version off `currentVersionId`. + */ + async findActiveById(props: { + id: string + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { id, workspaceId, tx = db } = props + return await tx.query.flowModel.findFirst({ + where: { id, workspaceId, active: true }, + }) + } + + /** + * Any active flow in the workspace, with NO ordering — used only as + * button-encoding context (e.g. `send-messenger-template.ts`). The + * "no ordering" behavior is intentional; do not add an `orderBy`. + */ + async findAnyActive(props: { + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, tx = db } = props + return await tx.query.flowModel.findFirst({ + where: { workspaceId, active: true }, + }) + } } export const flowService = new FlowService() diff --git a/packages/business/src/inbox/service.ts b/packages/business/src/inbox/service.ts index 60041f8f6e..05596a1c14 100644 --- a/packages/business/src/inbox/service.ts +++ b/packages/business/src/inbox/service.ts @@ -16,6 +16,8 @@ import { inboxModel } from "@chatbotx.io/database/schema" import type { InboxModel, InboxWithIntegrations, + IntegrationMessengerModel, + IntegrationWhatsappModel, } from "@chatbotx.io/database/types" import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" @@ -303,5 +305,50 @@ class InboxService extends BaseService { .limit(1) return !!row } + + /** + * Inbox + `integrationMessenger` relation, with an explicit return type so + * the relation survives inference (a bare `typeof db.query.inboxModel + * .findFirst` with no call resolves to the no-`with` overload and drops + * the relation — see `messenger-template-handler.ts`'s prior local + * workaround). Unscoped by `id` only — safe today because its sole caller + * (`messenger-template-handler.ts`) receives `inboxId` from a + * webhook-resolved, already workspace-scoped context and has no + * `workspaceId` in scope to filter by. + */ + async findWithIntegrationMessengerByIdUnscoped(props: { + id: string + tx?: DatabaseClient + }): Promise< + | (InboxModel & { integrationMessenger: IntegrationMessengerModel | null }) + | undefined + > { + const { id, tx = db } = props + return await tx.query.inboxModel.findFirst({ + where: { id }, + with: { integrationMessenger: true }, + }) + } + + /** + * Inbox + `integrationWhatsapp` relation — same explicit-return-type + * reasoning as above. Unscoped by `id` only — safe today because its sole + * caller (`wa-template-handler.ts`) receives `inboxId` from a + * webhook-resolved, already workspace-scoped context and has no + * `workspaceId` in scope to filter by. + */ + async findWithIntegrationWhatsappByIdUnscoped(props: { + id: string + tx?: DatabaseClient + }): Promise< + | (InboxModel & { integrationWhatsapp: IntegrationWhatsappModel | null }) + | undefined + > { + const { id, tx = db } = props + return await tx.query.inboxModel.findFirst({ + where: { id }, + with: { integrationWhatsapp: true }, + }) + } } export const inboxService = new InboxService() diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index b3d224760e..c2776573bf 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -11,6 +11,7 @@ export * from "./automation-throttle" export * from "./bot-field" export * from "./broadcast" export * from "./coexist" +export * from "./coexist-import" export * from "./contact" export * from "./contact-custom-field" export * from "./contact-export" @@ -67,6 +68,7 @@ export * from "./message" export * from "./message-cleanup" export * from "./messaging-ads" export * from "./messaging-ads-connection" +export * from "./messenger-message-template" export * from "./meta-catalog" export * from "./meta-conversions" export * from "./net" @@ -93,7 +95,9 @@ export * from "./types" export * from "./user" export * from "./user-quota" export * from "./webhook" +export * from "./whatsapp-flow" export * from "./whatsapp-flow-response" +export * from "./whatsapp-message-template" export * from "./workspace" export * from "./workspace-api-token" export * from "./workspace-lifecycle" diff --git a/packages/business/src/integration-ai-provider/connect.ts b/packages/business/src/integration-ai-provider/connect.ts new file mode 100644 index 0000000000..45fa6d78ba --- /dev/null +++ b/packages/business/src/integration-ai-provider/connect.ts @@ -0,0 +1,87 @@ +import { db, eq } from "@chatbotx.io/database/client" +import { integrationModel } from "@chatbotx.io/database/schema" +import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" +import type { AnyPgTable } from "drizzle-orm/pg-core" + +type AiProviderTable = AnyPgTable & { + id: unknown + integrationId: unknown + workspaceId: unknown +} + +export type ConnectAiProviderInput = { + workspaceId: string + apiKey: string + model: string + temperature: number + maxOutputTokens: number +} + +/** + * Shared connect logic for the claude/deepseek/gemini/openai tables — they are + * structurally identical (auth/model/temperature/maxOutputTokens/workspaceId/ + * integrationId), so a single upsert-by-workspace routine avoids four + * copy-paste clones. Callers pass their own table + integrationType; extra + * per-provider columns (e.g. openai's autoReplyVoice) are layered on by the + * caller via `extraInsertValues`/`extraUpdateValues`. + * + * The insert/update value objects are cast to `never` rather than the table + * itself — dispatching a single write across four structurally-similar but + * not statically-unifiable table types is the deliberate tradeoff of this + * consolidation; each table's own schema still enforces shape at the DB + * layer, and this function only ever writes the columns all four share. + */ +export async function connectAiProviderIntegration(props: { + table: AiProviderTable + integrationType: string + input: ConnectAiProviderInput + existing: { id: string } | null | undefined + extraInsertValues?: Record + extraUpdateValues?: Record +}) { + const auth: SecretTextAuthValue = { + authType: AuthType.secretText, + secretText: props.input.apiKey, + } + + await db.transaction(async (tx) => { + if (props.existing) { + await tx + .update(props.table) + .set({ + model: props.input.model, + auth, + temperature: props.input.temperature, + maxOutputTokens: props.input.maxOutputTokens, + ...props.extraUpdateValues, + } as never) + .where(eq(props.table.id as never, props.existing.id)) + return + } + + const [integration] = await tx + .insert(integrationModel) + .values({ + id: createId(), + workspaceId: props.input.workspaceId, + integrationType: props.integrationType, + }) + .returning() + + if (!integration) { + throw new Error("Failed to create integration record") + } + + await tx.insert(props.table).values({ + id: createId(), + integrationId: integration.id, + workspaceId: props.input.workspaceId, + model: props.input.model, + auth, + temperature: props.input.temperature, + maxOutputTokens: props.input.maxOutputTokens, + ...props.extraInsertValues, + } as never) + }) +} diff --git a/packages/business/src/integration-claude/service.ts b/packages/business/src/integration-claude/service.ts index 162a8abc4a..8e465c1507 100644 --- a/packages/business/src/integration-claude/service.ts +++ b/packages/business/src/integration-claude/service.ts @@ -1,12 +1,57 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationClaudeModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateClaudeInput = { autoReply?: boolean } class IntegrationClaudeService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationClaudeModel.findFirst({ where: { workspaceId } }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationClaudeModel, + integrationType: "claude", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the Claude integration configuration" + : "connected a new Claude integration", + ) + } + + async update(props: { workspaceId: string }, data: UpdateClaudeInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) + if (!existing) { + throw new Error("Integration Claude not found") + } + + const result = await db + .update(integrationClaudeModel) + .set(data) + .where(eq(integrationClaudeModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) + + await this.audit("update", "updated the Claude integration configuration") + + return result + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-deepseek/service.ts b/packages/business/src/integration-deepseek/service.ts index 7dae897594..3a3bee10d4 100644 --- a/packages/business/src/integration-deepseek/service.ts +++ b/packages/business/src/integration-deepseek/service.ts @@ -1,6 +1,15 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationDeepseekModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateDeepSeekInput = { autoReply?: boolean } class IntegrationDeepSeekService extends BaseService { findByWorkspaceId(workspaceId: string) { @@ -9,6 +18,42 @@ class IntegrationDeepSeekService extends BaseService { }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationDeepseekModel, + integrationType: "deepseek", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the DeepSeek integration configuration" + : "connected a new DeepSeek integration", + ) + } + + async update(props: { workspaceId: string }, data: UpdateDeepSeekInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) + if (!existing) { + throw new Error("Integration DeepSeek not found") + } + + const result = await db + .update(integrationDeepseekModel) + .set(data) + .where(eq(integrationDeepseekModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) + + await this.audit("update", "updated the DeepSeek integration configuration") + + return result + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-gemini/service.ts b/packages/business/src/integration-gemini/service.ts index 2361c5e0e9..c2eefc82e6 100644 --- a/packages/business/src/integration-gemini/service.ts +++ b/packages/business/src/integration-gemini/service.ts @@ -1,12 +1,57 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationGeminiModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateGeminiInput = { autoReply?: boolean } class IntegrationGeminiService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationGeminiModel.findFirst({ where: { workspaceId } }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationGeminiModel, + integrationType: "gemini", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the Gemini integration configuration" + : "connected a new Gemini integration", + ) + } + + async update(props: { workspaceId: string }, data: UpdateGeminiInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) + if (!existing) { + throw new Error("Integration Gemini not found") + } + + const result = await db + .update(integrationGeminiModel) + .set(data) + .where(eq(integrationGeminiModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) + + await this.audit("update", "updated the Gemini integration configuration") + + return result + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-google-sheet/service.ts b/packages/business/src/integration-google-sheet/service.ts index a8d6a1d721..4faebe00a3 100644 --- a/packages/business/src/integration-google-sheet/service.ts +++ b/packages/business/src/integration-google-sheet/service.ts @@ -1,4 +1,5 @@ -import { db } from "@chatbotx.io/database/client" +import { db, eq } from "@chatbotx.io/database/client" +import { integrationModel } from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" class IntegrationGoogleSheetService extends BaseService { @@ -17,6 +18,14 @@ class IntegrationGoogleSheetService extends BaseService { } return integration } + + async disconnect(integrationId: string): Promise { + await db.transaction(async (tx) => { + await tx + .delete(integrationModel) + .where(eq(integrationModel.id, integrationId)) + }) + } } export const integrationGoogleSheetService = new IntegrationGoogleSheetService() diff --git a/packages/business/src/integration-instagram/service.ts b/packages/business/src/integration-instagram/service.ts index bc1c988ea0..1e510a136c 100644 --- a/packages/business/src/integration-instagram/service.ts +++ b/packages/business/src/integration-instagram/service.ts @@ -1,4 +1,11 @@ -import { and, db, eq, findOrFail, sql } from "@chatbotx.io/database/client" +import { + and, + type DatabaseClient, + db, + eq, + findOrFail, + sql, +} from "@chatbotx.io/database/client" import type { InstagramPersistentMenu, IntegrationUserInfo, @@ -270,6 +277,30 @@ class InstagramIntegrationService extends BaseService { return { integration, wasCreated } }) } + + listByWorkspaceId(workspaceId: string) { + return db.query.integrationInstagramModel.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "asc" }, + }) + } + + async updateProfileFields( + props: { id: string }, + data: Record, + tx: DatabaseClient, + ) { + await tx + .update(integrationInstagramModel) + .set(data) + .where(eq(integrationInstagramModel.id, props.id)) + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(integrationInstagramModel) + .where(eq(integrationInstagramModel.id, props.id)) + } } export const instagramIntegrationService = new InstagramIntegrationService() diff --git a/packages/business/src/integration-messenger/service.ts b/packages/business/src/integration-messenger/service.ts index 12bb3810af..a56ba7bf25 100644 --- a/packages/business/src/integration-messenger/service.ts +++ b/packages/business/src/integration-messenger/service.ts @@ -1,17 +1,22 @@ import { and, + type DatabaseClient, db, eq, findOrFail, inArray, sql, } from "@chatbotx.io/database/client" -import type { - IntegrationUserInfo, - MessengerPersistentMenu, +import { + channelTypes, + type IntegrationUserInfo, + type MessengerPersistentMenu, } from "@chatbotx.io/database/partials" import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { + integrationMessengerModel, + tagChannelModel, +} from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" @@ -55,6 +60,16 @@ class MessengerIntegrationService extends BaseService { }) } + /** No workspace scope — targets in a clone-across-workspaces flow may live in other workspaces. */ + findByIds(ids: string[]) { + if (ids.length === 0) { + return Promise.resolve([]) + } + return db.query.integrationMessengerModel.findMany({ + where: { id: { in: ids } }, + }) + } + findByPageId(props: { workspaceId: string; pageId: string }) { return db.query.integrationMessengerModel.findFirst({ where: { workspaceId: props.workspaceId, pageId: props.pageId }, @@ -239,6 +254,84 @@ class MessengerIntegrationService extends BaseService { return rows.length > 0 } + + /** + * Load by id with NO workspace scope — delegates to the repository. + * Callers that separately have a `workspaceId` must compare it themselves + * (see `coexist/messenger-sync.ts`'s explicit-mismatch branch); this must + * NOT be used as a substitute for `findByIdForWorkspace`. + */ + findById(props: { id: string }) { + return integrationMessengerRepository.findById(props) + } + + /** + * Load by Facebook page id with NO workspace scope — for inbound webhooks + * that have not yet resolved a workspace (e.g. inbox-label sync). + */ + findByPageIdUnscoped(props: { pageId: string }) { + return integrationMessengerRepository.findByPageIdUnscoped(props) + } + + listByWorkspaceIdOrId( + where: Partial>, + ) { + return db.query.integrationMessengerModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + async updateTagSync(props: { + workspaceId: string + integrationId: string + enabled: boolean + }): Promise { + const updated = await db + .update(integrationMessengerModel) + .set({ syncTagEnabledAt: props.enabled ? new Date() : null }) + .where( + and( + eq(integrationMessengerModel.id, props.integrationId), + eq(integrationMessengerModel.workspaceId, props.workspaceId), + ), + ) + .returning({ + syncTagEnabledAt: integrationMessengerModel.syncTagEnabledAt, + }) + + return updated[0]?.syncTagEnabledAt ?? null + } + + async updateProfileFields( + props: { id: string }, + data: Record, + tx: DatabaseClient, + ) { + await tx + .update(integrationMessengerModel) + .set(data) + .where(eq(integrationMessengerModel.id, props.id)) + } + + /** + * Deletes the integration row and its polymorphic TagChannel entries within + * the caller's transaction. Coexist teardown, remote unsubscribe, and inbox + * disconnect stay orchestrated by the caller. + */ + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.messenger), + eq(tagChannelModel.integrationId, props.id), + ), + ) + await props.tx + .delete(integrationMessengerModel) + .where(eq(integrationMessengerModel.id, props.id)) + } } export const messengerIntegrationService = new MessengerIntegrationService() diff --git a/packages/business/src/integration-openai/service.ts b/packages/business/src/integration-openai/service.ts index 13c52aa027..60942d796e 100644 --- a/packages/business/src/integration-openai/service.ts +++ b/packages/business/src/integration-openai/service.ts @@ -1,12 +1,66 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationModel, + integrationOpenaiModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateOpenAIInput = { autoReply?: boolean } class IntegrationOpenAIService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationOpenaiModel.findFirst({ where: { workspaceId } }) } + findByWorkspaceIdAndId(props: { workspaceId: string; id: string }) { + return db.query.integrationOpenaiModel.findFirst({ + where: { workspaceId: props.workspaceId, id: props.id }, + }) + } + + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationOpenaiModel, + integrationType: "openai", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the OpenAI integration configuration" + : "connected a new OpenAI integration", + ) + } + + async update( + props: { workspaceId: string; id: string }, + data: UpdateOpenAIInput, + ) { + const existing = await this.findByWorkspaceIdAndId(props) + if (!existing) { + throw new Error("Integration OpenAI not found") + } + + const result = await db + .update(integrationOpenaiModel) + .set(data) + .where(eq(integrationOpenaiModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) + + await this.audit("update", "updated the OpenAI integration configuration") + + return result + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-smtp/service.ts b/packages/business/src/integration-smtp/service.ts index 4882c1c38e..3ddb69e295 100644 --- a/packages/business/src/integration-smtp/service.ts +++ b/packages/business/src/integration-smtp/service.ts @@ -1,6 +1,40 @@ -import { db } from "@chatbotx.io/database/client" +import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { channelTypes } from "@chatbotx.io/database/partials" +import { integrationSmtpModel } from "@chatbotx.io/database/schema" import type { IntegrationSmtpModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { isSameJsonValue } from "../audit/diff" import { BaseService } from "../base.service" +import { ChatbotXException } from "../errors" +import { connectChannelIntegration } from "../inbox/connect-channel" +import { inboxService } from "../inbox/service" +import { workspaceService } from "../workspace/service" + +/** + * Mirrors `@chatbotx.io/integration-smtp`'s `SmtpAuthValue` structurally — + * `packages/business` must not depend on an `integrations/*` package, so the + * caller (app layer) resolves `provider`'s default host/port via that + * package's `smtpHostMap` before calling `create`/`update`. + */ +export type SmtpAuthValue = { + authType: "custom" + provider: string + host: string + port: number + username: string + password: string +} + +export type CreateSmtpInput = { + provider: string + host: string + port: number + username: string + password: string + fromAddress: string +} + +export type UpdateSmtpInput = CreateSmtpInput class IntegrationSmtpService extends BaseService { find({ @@ -8,16 +42,165 @@ class IntegrationSmtpService extends BaseService { }: { where: Partial<{ workspaceId: string; id: string }> }): Promise { - // return withCache( - // `integrationSmtp:find:${btoa(JSON.stringify(where))}`, - // () => return db.query.integrationSmtpModel.findFirst({ where, }) - // { - // tags: ["integrationSmtp"], - // }, - // ) + } + + listByWorkspaceId(workspaceId: string) { + return db.query.integrationSmtpModel.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }) + } + + findByIdForWorkspace(props: { id: string; workspaceId: string }) { + return findOrFail({ + table: integrationSmtpModel, + where: props, + message: "SMTP integration not found", + }) + } + + /** + * Callers must: + * 1. verify the SMTP connection (via `verifySmtpConnection` in + * `apps/builder/src/features/integration-smtp/services/smtp.service.ts`) + * — needs `next-intl` to translate the failure message, which a service + * cannot call. + * 2. resolve `input.host`/`input.port` to the provider's default (via that + * same file's `smtpHostMap`) when `input.provider !== "other"` — the + * map lives in `@chatbotx.io/integration-smtp`, which `packages/business` + * must not depend on. + */ + async create( + workspaceId: string, + input: CreateSmtpInput, + ): Promise<{ id: string }> { + const { host, port } = input + + const workspace = await workspaceService.find({ + where: { id: workspaceId }, + }) + if (!workspace) { + throw new ChatbotXException("Workspace not found") + } + + const { inbox, wasCreated } = await db.transaction(async (tx) => { + const smtpId = createId() + const name = input.username + + return await connectChannelIntegration({ + tx, + ownerId: workspace.ownerId, + inboxData: { + id: smtpId, + workspaceId, + channel: channelTypes.enum.smtp, + name, + sourceId: smtpId, + }, + insertIntegration: async (inboxId) => { + await tx.insert(integrationSmtpModel).values({ + id: smtpId, + name, + workspaceId, + inboxId, + fromAddress: input.fromAddress, + auth: { + authType: "custom" as const, + provider: input.provider, + username: input.username, + password: input.password, + host, + port, + }, + }) + }, + }) + }) + + if (wasCreated) { + await this.audit("connect", `connected a new SMTP channel (#${inbox.id})`) + } + + return inbox + } + + async update( + workspaceId: string, + id: string, + input: UpdateSmtpInput, + ): Promise { + const integration = await this.findByIdForWorkspace({ id, workspaceId }) + const currentAuth = integration.auth as SmtpAuthValue + const provider = input.provider ?? currentAuth.provider + const host = input.host || currentAuth.host + const port = input.port || currentAuth.port + + const updatedAuth: SmtpAuthValue = { + authType: "custom", + provider, + host, + port, + username: input.username ?? currentAuth.username, + password: input.password ?? currentAuth.password, + } + + const name = input.username ?? integration.name + const fromAddress = input.fromAddress ?? integration.fromAddress + + const updated = await db + .update(integrationSmtpModel) + .set({ auth: updatedAuth, name, fromAddress }) + .where(eq(integrationSmtpModel.id, integration.id)) + .returning() + .then((result) => result[0]) + + if (!updated) { + throw new Error("Failed to update SMTP integration") + } + + const hasChanged = !isSameJsonValue( + { auth: updatedAuth, name, fromAddress }, + { + auth: currentAuth, + name: integration.name, + fromAddress: integration.fromAddress, + }, + ) + + if (hasChanged) { + await this.audit("update", "updated the SMTP channel configuration") + } + + return updated + } + + async delete(workspaceId: string, id: string): Promise { + const [integration, workspace] = await Promise.all([ + this.findByIdForWorkspace({ id, workspaceId }), + workspaceService.findById({ id: workspaceId }), + ]) + + await db.transaction(async (tx) => { + await tx + .delete(integrationSmtpModel) + .where(eq(integrationSmtpModel.id, integration.id)) + + await inboxService.disconnect({ + inboxId: integration.inboxId, + ownerId: workspace.ownerId, + workspaceId, + reason: "manual", + tx, + }) + }) + + await this.audit( + "disconnect", + `disconnected the SMTP channel (#${integration.id})`, + ) } } export const integrationSmtpService = new IntegrationSmtpService() diff --git a/packages/business/src/integration-telegram/service.ts b/packages/business/src/integration-telegram/service.ts index 263eb604ad..654aba5c9b 100644 --- a/packages/business/src/integration-telegram/service.ts +++ b/packages/business/src/integration-telegram/service.ts @@ -1,6 +1,24 @@ -import { findOrFail } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + eq, + findOrFail, +} from "@chatbotx.io/database/client" import { integrationTelegramModel } from "@chatbotx.io/database/schema" +import type { IntegrationTelegramModel } from "@chatbotx.io/database/types" +import type { SecretTextAuthValue } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectTelegramInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + botId: string + botUsername: string + botToken: string +} class TelegramIntegrationService extends BaseService { findByInboxIdForWorkspace(props: { inboxId: string; workspaceId: string }) { @@ -9,6 +27,74 @@ class TelegramIntegrationService extends BaseService { where: { inboxId: props.inboxId, workspaceId: props.workspaceId }, }) } + + findByWorkspaceIdAndId(props: { workspaceId: string; id: string }) { + return findOrFail({ + table: integrationTelegramModel, + where: { workspaceId: props.workspaceId, id: props.id }, + message: "Integration Telegram not found", + }) + } + + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationTelegramModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationTelegramModel.findFirst({ + where: { workspaceId }, + }) + } + + /** No auth check — for use by the webhook handler only. */ + findByBotId(botId: string) { + return db.query.integrationTelegramModel.findFirst({ + where: { botId }, + }) + } + + async connect(input: ConnectTelegramInput) { + const auth: SecretTextAuthValue = { + authType: "secretText", + secretText: input.botToken, + } + const integrationId = createId() + + const { wasCreated } = await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + id: createId(), + workspaceId: input.workspaceId, + name: input.botUsername, + channel: "telegram", + sourceId: input.botId, + }, + insertIntegration: async (inboxId) => { + await input.tx.insert(integrationTelegramModel).values({ + id: integrationId, + inboxId, + workspaceId: input.workspaceId, + botId: input.botId, + name: input.botUsername, + auth, + }) + }, + }) + + return { integrationId, wasCreated } + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(integrationTelegramModel) + .where(eq(integrationTelegramModel.id, props.id)) + } } export const telegramIntegrationService = new TelegramIntegrationService() diff --git a/packages/business/src/integration-tiktok/service.ts b/packages/business/src/integration-tiktok/service.ts index 50a74b3ab1..0425345440 100644 --- a/packages/business/src/integration-tiktok/service.ts +++ b/packages/business/src/integration-tiktok/service.ts @@ -1,6 +1,25 @@ -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" import { integrationTiktokModel } from "@chatbotx.io/database/schema" +import type { IntegrationTiktokModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectTiktokInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + openId: string + username: string + displayName: string + auth: Record +} class TiktokIntegrationService extends BaseService { findById(props: { id: string; workspaceId: string }) { @@ -11,6 +30,67 @@ class TiktokIntegrationService extends BaseService { }) } + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationTiktokModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationTiktokModel.findFirst({ where: { workspaceId } }) + } + + findByOpenId(openId: string) { + return db.query.integrationTiktokModel.findFirst({ where: { openId } }) + } + + async connect(input: ConnectTiktokInput) { + const integrationId = createId() + + return await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + workspaceId: input.workspaceId, + name: input.displayName, + channel: "tiktok", + sourceId: input.username, + }, + insertIntegration: async (inboxId) => { + const [integration] = await input.tx + .insert(integrationTiktokModel) + .values({ + id: integrationId, + inboxId, + workspaceId: input.workspaceId, + openId: input.openId, + name: input.displayName, + auth: input.auth, + }) + .onConflictDoUpdate({ + target: [integrationTiktokModel.openId], + set: { + auth: input.auth, + name: input.displayName, + tokenRefreshError: null, + }, + }) + .returning({ id: integrationTiktokModel.id }) + + return integration + }, + }) + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(integrationTiktokModel) + .where(eq(integrationTiktokModel.id, props.id)) + } + findAll() { return db .select({ diff --git a/packages/business/src/integration-webchat/service.ts b/packages/business/src/integration-webchat/service.ts index 3291f26272..836caae938 100644 --- a/packages/business/src/integration-webchat/service.ts +++ b/packages/business/src/integration-webchat/service.ts @@ -23,6 +23,8 @@ export type CreateWebchatRequest = { welcomeFlowId?: string | null } +export type UpdateWebchatRequest = Partial + class IntegrationWebchatService extends BaseService { /** * Provisions a new Inbox + IntegrationWebchat row together, mirroring @@ -86,6 +88,56 @@ class IntegrationWebchatService extends BaseService { return created } + findByWorkspaceIdAndId(where: { id: string; workspaceId: string }) { + return findOrFail({ + table: integrationWebchatModel, + where, + message: "Integration webchat not found", + }) + } + + listByWorkspaceId(props: { + workspaceId: string + pagination?: { limit: number; offset: number } | null + }) { + const where = { workspaceId: props.workspaceId } + return Promise.all([ + db.query.integrationWebchatModel.findMany({ + where, + orderBy: { createdAt: "desc" }, + ...props.pagination, + }), + props.pagination?.limit + ? db.$count( + integrationWebchatModel, + eq(integrationWebchatModel.workspaceId, props.workspaceId), + ) + : Promise.resolve(1), + ]) + } + + async update( + where: { workspaceId: string; id: string }, + data: UpdateWebchatRequest & { + welcomeFlowId?: string | null + authorizedDomains?: string[] + }, + ): Promise { + const existing = await this.findByWorkspaceIdAndId(where) + + await db.transaction(async (tx) => { + await tx + .update(integrationWebchatModel) + .set({ + ...data, + conversationStarters: data.conversationStarters as never, + persistentMenus: data.persistentMenus as never, + workspaceId: where.workspaceId, + }) + .where(eq(integrationWebchatModel.id, existing.id)) + }) + } + async delete(input: { workspaceId: string; id: string }): Promise { const [integrationWebchat, workspace] = await Promise.all([ findOrFail({ diff --git a/packages/business/src/integration-whatsapp/service.ts b/packages/business/src/integration-whatsapp/service.ts index 6903f242fe..f97f7303a0 100644 --- a/packages/business/src/integration-whatsapp/service.ts +++ b/packages/business/src/integration-whatsapp/service.ts @@ -1,10 +1,22 @@ -import type { DatabaseClient } from "@chatbotx.io/database/client" +import { + and, + type DatabaseClient, + eq, + inArray, +} from "@chatbotx.io/database/client" import type { WhatsappRegistrationStatus } from "@chatbotx.io/database/partials" import { integrationWhatsappRepository, + LIVE_RUN_STATUSES, + metaCapiEventRepository, whatsappSignupSessionRepository, } from "@chatbotx.io/database/repositories" -import type { IntegrationWhatsappRegistrationError } from "@chatbotx.io/database/schema" +import { + coexistSyncRunModel, + type IntegrationWhatsappRegistrationError, + integrationWhatsappModel, + whatsappCoexistStagingModel, +} from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, WhatsappSignupSessionModel, @@ -13,6 +25,7 @@ import { encryptedDataSchema, encryptUtils } from "@chatbotx.io/encryption" import type { ChannelError } from "@chatbotx.io/sdk" import { z } from "zod" import { BaseService } from "../base.service" +import { inboxService } from "../inbox/service" import { logger } from "../logger" import { createDatasetWithFallback } from "../meta-conversions/dataset-fallback" import { platformCredentialService } from "../platform-credential/service" @@ -354,6 +367,17 @@ class IntegrationWhatsappService extends BaseService { return integrationWhatsappRepository.markTokenRefreshError(id, error) } + /** + * No workspace scope — for the inbound webhook-verification handler, which + * has not yet resolved a workspace when it stamps `webhookVerifiedAt`. + */ + markWebhookVerified( + id: string, + auth: Record, + ): Promise { + return integrationWhatsappRepository.updateAuthUnscoped(id, auth) + } + async refreshCapiScopeCache( input: RefreshCapiScopeCacheInput, ): Promise { @@ -612,6 +636,78 @@ class IntegrationWhatsappService extends BaseService { setCoexist(input: SetCoexistInput): Promise { return setCoexist(input) } + + /** Mark that the operator declined the coexist history-import prompt. */ + markHistoryDeclined(props: { id: string }): Promise { + return integrationWhatsappRepository.markHistoryDeclined(props) + } + + /** + * Everything one disconnect must abandon or delete, in a single + * transaction. Sync history (importedCount / lastSyncedAt / …) is + * deliberately preserved for audit and so a reconnect can resume from the + * prior watermark; only ACTIVE runs are abandoned so the scheduler stops + * trying to drive them forward against a now-missing integration. + * `LIVE_RUN_STATUSES` includes `waiting`: a WhatsApp coexist run parked for + * more Meta history must be abandoned here too, otherwise the scheduler + * cannot revive it (its staging rows are deleted below) and it lingers + * until the 24h history-window timeout closes it. + */ + async disconnect(props: { + integrationWhatsapp: IntegrationWhatsappModel + ownerId: string + workspaceId: string + tx: DatabaseClient + }): Promise { + const { integrationWhatsapp, ownerId, workspaceId, tx } = props + + await tx + .update(coexistSyncRunModel) + .set({ + status: "failed", + finishedAt: new Date(), + currentError: "Integration disconnected", + }) + .where( + and( + eq(coexistSyncRunModel.integrationId, integrationWhatsapp.id), + inArray(coexistSyncRunModel.status, LIVE_RUN_STATUSES), + ), + ) + + 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, + workspaceId, + reason: "manual", + tx, + }) + } } export const integrationWhatsappService = new IntegrationWhatsappService() diff --git a/packages/business/src/integration-zalo/service.ts b/packages/business/src/integration-zalo/service.ts index c38cbdd510..2ade897868 100644 --- a/packages/business/src/integration-zalo/service.ts +++ b/packages/business/src/integration-zalo/service.ts @@ -1,8 +1,113 @@ -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { integrationZaloModel } from "@chatbotx.io/database/schema" +import { + and, + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" +import { channelTypes } from "@chatbotx.io/database/partials" +import { + integrationZaloModel, + tagChannelModel, +} from "@chatbotx.io/database/schema" +import type { IntegrationZaloModel } from "@chatbotx.io/database/types" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectZaloInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + oaId: string + oaName: string + auth: Record +} class ZaloIntegrationService extends BaseService { + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationZaloModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationZaloModel.findFirst({ where: { workspaceId } }) + } + + /** + * Returns `wasCreated: false` (no insert performed) when the OA is already + * connected elsewhere — the caller (app layer) decides whether to redirect; + * `redirect()` must not be called from inside a service. + */ + async connect( + input: ConnectZaloInput, + ): Promise<{ integrationId: string | undefined; wasCreated: boolean }> { + let connectedIntegrationId: string | undefined + + const { wasCreated } = await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + workspaceId: input.workspaceId, + name: input.oaName, + channel: "zalo", + sourceId: input.oaId, + }, + insertIntegration: async (inboxId, insertWasCreated) => { + if (!insertWasCreated) { + return + } + const [row] = await input.tx + .insert(integrationZaloModel) + .values({ + inboxId, + workspaceId: input.workspaceId, + oaId: input.oaId, + auth: input.auth, + name: input.oaName, + }) + .returning({ id: integrationZaloModel.id }) + connectedIntegrationId = row?.id + }, + }) + + return { integrationId: connectedIntegrationId, wasCreated } + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId + await props.tx + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.zalo), + eq(tagChannelModel.integrationId, props.id), + ), + ) + await props.tx + .delete(integrationZaloModel) + .where(eq(integrationZaloModel.id, props.id)) + } + + async updateTagSync(props: { + workspaceId: string + integrationId: string + enabled: boolean + }) { + await db + .update(integrationZaloModel) + .set({ syncTagEnabledAt: props.enabled ? new Date() : null }) + .where( + and( + eq(integrationZaloModel.id, props.integrationId), + eq(integrationZaloModel.workspaceId, props.workspaceId), + ), + ) + } async findAll(): Promise< Array<{ id: string; workspaceId: string; auth: Record }> > { @@ -65,6 +170,17 @@ class ZaloIntegrationService extends BaseService { .set({ tokenRefreshError: error }) .where(eq(integrationZaloModel.id, id)) } + + /** + * Load a Zalo integration by OA id with NO workspace scope — used by + * inbound webhooks (e.g. inbox-label sync) that only have the OA id and + * have not yet resolved a workspace. + */ + findByOaId(props: { oaId: string }) { + return db.query.integrationZaloModel.findFirst({ + where: { oaId: props.oaId }, + }) + } } export const zaloIntegrationService = new ZaloIntegrationService() diff --git a/packages/business/src/integration/service.ts b/packages/business/src/integration/service.ts index 5e2c0524d6..6a0c5ead30 100644 --- a/packages/business/src/integration/service.ts +++ b/packages/business/src/integration/service.ts @@ -36,6 +36,40 @@ export type TokenRefreshErrorIntegration = { } class IntegrationService extends BaseService { + findByIdForWorkspace(props: { + id: string + workspaceId: string + }): Promise { + return db.query.integrationModel.findFirst({ + where: { id: props.id, workspaceId: props.workspaceId }, + }) + } + + async listByWorkspaceIdAndTypes(props: { + workspaceId: string + integrationTypes: string[] + }): Promise { + return await db.query.integrationModel.findMany({ + where: { + integrationType: { in: props.integrationTypes }, + workspaceId: props.workspaceId, + }, + }) + } + + async existsByWorkspaceIdAndTypes(props: { + workspaceId: string + integrationTypes: string[] + }): Promise { + const existing = await db.query.integrationModel.findFirst({ + where: { + integrationType: { in: props.integrationTypes }, + workspaceId: props.workspaceId, + }, + }) + return !!existing + } + async listByWorkspaceId(workspaceId: string): Promise { return await db .select() diff --git a/packages/business/src/messenger-message-template/index.ts b/packages/business/src/messenger-message-template/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/messenger-message-template/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/messenger-message-template/service.ts b/packages/business/src/messenger-message-template/service.ts new file mode 100644 index 0000000000..ad3523eb62 --- /dev/null +++ b/packages/business/src/messenger-message-template/service.ts @@ -0,0 +1,274 @@ +import { + and, + type DatabaseClient, + db, + eq, + ilike, + inArray, +} from "@chatbotx.io/database/client" +import type { MessengerTemplateStatus } from "@chatbotx.io/database/partials" +import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" +import { + getPaginationWithDefaults, + likeContains, +} from "@chatbotx.io/database/utils" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +type MessengerMessageTemplateListWhere = { + workspaceId: string + inboxId?: string + integrationMessengerId?: string + status?: MessengerTemplateStatus + name?: string +} + +/** Meta's message-template shape, as returned by `listMessageTemplates`. */ +export type MetaMessengerTemplate = { + id: string + name: string + language: string + category: string + status: string + parameter_format?: string + components: unknown +} + +class MessengerMessageTemplateService extends BaseService { + private async resolveIntegrationMessengerId({ + tx, + where, + }: { + tx: DatabaseClient + where: MessengerMessageTemplateListWhere + }) { + let resolvedIntegrationMessengerId = where.integrationMessengerId + + if (!resolvedIntegrationMessengerId && where.inboxId) { + const integration = await tx.query.integrationMessengerModel.findFirst({ + where: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + columns: { id: true }, + }) + resolvedIntegrationMessengerId = integration?.id + } + + return resolvedIntegrationMessengerId + } + + async list(props: { + tx?: DatabaseClient + where: MessengerMessageTemplateListWhere + }) { + const { tx = db, where } = props + + // Resolve integrationMessengerId from inboxId when only inboxId is given. + // Relying on nested relational filtering for inboxId is fragile and ORM- + // version-sensitive because messengerMessageTemplateModel has no direct + // inboxId column. + const resolvedIntegrationMessengerId = + await this.resolveIntegrationMessengerId({ tx, where }) + + return tx.query.messengerMessageTemplateModel.findMany({ + where: { + status: where.status, + integrationMessengerId: resolvedIntegrationMessengerId, + integrationMessenger: { + workspaceId: where.workspaceId, + }, + }, + with: { + integrationMessenger: true, + }, + orderBy: { id: "desc" }, + }) + } + + async listPaginated(props: { + tx?: DatabaseClient + where: MessengerMessageTemplateListWhere + page?: number + perPage?: number + }) { + const { tx = db, where } = props + const resolvedIntegrationMessengerId = + await this.resolveIntegrationMessengerId({ tx, where }) + const queryWhere = { + name: where.name ? { ilike: likeContains(where.name) } : undefined, + status: where.status, + integrationMessengerId: resolvedIntegrationMessengerId, + integrationMessenger: { + workspaceId: where.workspaceId, + }, + } + const pagination = getPaginationWithDefaults({ + page: props.page, + perPage: props.perPage, + }) + + const [data, total] = await Promise.all([ + tx.query.messengerMessageTemplateModel.findMany({ + where: queryWhere, + with: { + integrationMessenger: true, + }, + orderBy: { id: "desc" }, + limit: pagination.limit, + offset: pagination.offset, + }), + tx.$count( + messengerMessageTemplateModel, + and( + where.name + ? ilike( + messengerMessageTemplateModel.name, + likeContains(where.name), + ) + : undefined, + where.status + ? eq(messengerMessageTemplateModel.status, where.status) + : undefined, + resolvedIntegrationMessengerId + ? eq( + messengerMessageTemplateModel.integrationMessengerId, + resolvedIntegrationMessengerId, + ) + : undefined, + ), + ), + ]) + + return { + data, + pageCount: Math.max(1, Math.ceil(total / pagination.limit)), + } + } + + findByIdForIntegration(props: { + id: string + integrationMessengerId: string + workspaceId: string + }) { + return db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationMessengerId: props.integrationMessengerId, + integrationMessenger: { workspaceId: props.workspaceId }, + }, + }) + } + + /** Approved-only template lookup for outbound template sends, scoped by workspace through the integration relation. */ + findApprovedByIdForIntegration(props: { + id: string + integrationMessengerId: string + workspaceId: string + }) { + return db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationMessengerId: props.integrationMessengerId, + integrationMessenger: { workspaceId: props.workspaceId }, + status: "APPROVED", + }, + }) + } + + async delete(props: { + id: string + integrationMessengerId: string + }): Promise { + await db + .delete(messengerMessageTemplateModel) + .where( + and( + eq(messengerMessageTemplateModel.id, props.id), + eq( + messengerMessageTemplateModel.integrationMessengerId, + props.integrationMessengerId, + ), + ), + ) + } + + /** + * Merges Meta's current template list into the local table for one + * integration: an exact-match query (`templateId`/`templateName`/ + * `templateLanguage` all set) is a partial sync — only those templates are + * upserted, existing rows outside the filter are left alone. A full sync + * (no filter) also deletes local rows Meta no longer reports. + */ + async syncFromMeta(props: { + integrationMessengerId: string + templates: MetaMessengerTemplate[] + isPartialSync: boolean + }): Promise { + await db.transaction(async (tx) => { + if (!props.isPartialSync) { + const existingTemplates = await tx + .select({ + id: messengerMessageTemplateModel.id, + sourceId: messengerMessageTemplateModel.sourceId, + }) + .from(messengerMessageTemplateModel) + .where( + eq( + messengerMessageTemplateModel.integrationMessengerId, + props.integrationMessengerId, + ), + ) + + const incomingSourceIds = new Set(props.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 props.templates) { + await tx + .insert(messengerMessageTemplateModel) + .values([ + { + id: createId(), + name: template.name, + integrationMessengerId: props.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 messengerMessageTemplateService = + new MessengerMessageTemplateService() diff --git a/packages/business/src/tag/service.ts b/packages/business/src/tag/service.ts index f143d5bc8b..c39c5ae8d8 100644 --- a/packages/business/src/tag/service.ts +++ b/packages/business/src/tag/service.ts @@ -17,9 +17,10 @@ import { contactModel, contactsToTagsModel, contactToTagChannelModel, + tagChannelModel, tagModel, } from "@chatbotx.io/database/schema" -import type { TagModel } from "@chatbotx.io/database/types" +import type { TagChannelModel, TagModel } from "@chatbotx.io/database/types" import { likeContains, parseOrderByAsObject, @@ -1030,6 +1031,225 @@ class TagService extends BaseService { .catch(() => {}) } } + + /** + * Unlink one workspace tag from many contacts (inbox-label unassign). + * `ContactToTag` has no `workspaceId` column, so this cannot be scoped + * without an extra join — safe today because every caller + * (`inbox_labels/sync.ts`) resolves `tagId`/`contactIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. + */ + async detachTagFromContactsUnscoped(props: { + tagId: string + contactIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagId, contactIds, tx = db } = props + if (contactIds.length === 0) { + return + } + await tx + .delete(contactsToTagsModel) + .where( + and( + eq(contactsToTagsModel.tagId, tagId), + inArray(contactsToTagsModel.contactId, contactIds), + ), + ) + } + + /** + * Link a workspace tag to many contacts, returning the NEWLY-linked + * contact ids (untargeted `onConflictDoNothing()` — verbatim from + * `inbox_labels/sync.ts` `assignLabel`). `ContactToTag` has no + * `workspaceId` column, so this cannot be scoped without an extra join — + * safe today because the caller resolves `tagId`/`contactIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. + */ + async linkTagToContactsReturningNewUnscoped(props: { + tagId: string + contactIds: string[] + tx?: DatabaseClient + }): Promise<{ contactId: string }[]> { + const { tagId, contactIds, tx = db } = props + if (contactIds.length === 0) { + return [] + } + return await tx + .insert(contactsToTagsModel) + .values(contactIds.map((contactId) => ({ contactId, tagId }))) + .onConflictDoNothing() + .returning({ contactId: contactsToTagsModel.contactId }) + } + + /** + * Record per-channel tag assignments (used for reconciliation / detach). + * `ContactToTagChannel` has no `workspaceId` column, so this cannot be + * scoped without an extra join — safe today because the caller + * (`inbox_labels/sync.ts`) resolves `tagId`/`tagChannelId`/ + * `contactInboxIds` from a workspace-scoped `ensureTagChannel` + + * `ctx.inboxId` lookup first. + */ + async recordTagChannelAssignmentsUnscoped(props: { + tagId: string + tagChannelId: string + contactInboxIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagId, tagChannelId, contactInboxIds, tx = db } = props + if (contactInboxIds.length === 0) { + return + } + await tx + .insert(contactToTagChannelModel) + .values( + contactInboxIds.map((contactInboxId) => ({ + tagId, + tagChannelId, + contactInboxId, + })), + ) + .onConflictDoNothing() + } + + /** + * Remove per-channel tag assignments (inbox-label unassign). + * `ContactToTagChannel` has no `workspaceId` column, so this cannot be + * scoped without an extra join — safe today because the caller + * (`inbox_labels/sync.ts`) resolves `tagChannelId`/`contactInboxIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. + */ + async deleteTagChannelAssignmentsUnscoped(props: { + tagChannelId: string + contactInboxIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagChannelId, contactInboxIds, tx = db } = props + if (contactInboxIds.length === 0) { + return + } + await tx + .delete(contactToTagChannelModel) + .where( + and( + eq(contactToTagChannelModel.tagChannelId, tagChannelId), + inArray(contactToTagChannelModel.contactInboxId, contactInboxIds), + ), + ) + } + + /** Find the channel mapping for an external label id. */ + async findTagChannel(props: { + workspaceId: string + channelType: TagChannelModel["channelType"] + integrationId: string + externalLabelId: string + tx?: DatabaseClient + }): Promise | undefined> { + const { + workspaceId, + channelType, + integrationId, + externalLabelId, + tx = db, + } = props + return await tx.query.tagChannelModel.findFirst({ + where: { workspaceId, channelType, integrationId, externalLabelId }, + columns: { id: true, tagId: true }, + }) + } + + /** + * Get-or-create a tag by name — moved VERBATIM from `inbox_labels/sync.ts` + * `ensureTag`, including the three-step race handling (find → insert with + * the partial-unique `onConflictDoNothing` → read-back retry on a lost + * race). Do not simplify. + */ + async ensureTagByName(props: { + workspaceId: string + name: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, name, tx = db } = props + const where = { workspaceId, name, deletedAt: { isNull: true as const } } + + const found = await tx.query.tagModel.findFirst({ + where, + columns: { id: true }, + }) + if (found) { + return found.id + } + + const [created] = await tx + .insert(tagModel) + .values({ id: createId(), workspaceId, name }) + .onConflictDoNothing({ + // Tag_workspaceId_name_key is a partial unique index (deletedAt IS NULL). + target: [tagModel.workspaceId, tagModel.name], + where: isNull(tagModel.deletedAt), + }) + .returning({ id: tagModel.id }) + if (created) { + return created.id + } + + // Lost a race against a concurrent insert — read the winner back. + const retry = await tx.query.tagModel.findFirst({ + where, + columns: { id: true }, + }) + return retry?.id + } + + /** + * Get-or-create a tag's channel mapping — moved VERBATIM from + * `inbox_labels/sync.ts` `ensureChannel`, including the read-back retry. + */ + async ensureTagChannel(props: { + workspaceId: string + tagId: string + channelType: TagChannelModel["channelType"] + integrationId: string + externalLabelId: string + tx?: DatabaseClient + }): Promise { + const { + workspaceId, + tagId, + channelType, + integrationId, + externalLabelId, + tx = db, + } = props + const [created] = await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId, + tagId, + channelType, + integrationId, + externalLabelId, + }) + .onConflictDoNothing({ + target: [ + tagChannelModel.tagId, + tagChannelModel.channelType, + tagChannelModel.integrationId, + ], + }) + .returning({ id: tagChannelModel.id }) + if (created) { + return created.id + } + + const retry = await tx.query.tagChannelModel.findFirst({ + where: { tagId, workspaceId, channelType, integrationId }, + columns: { id: true }, + }) + return retry?.id + } } export const tagService = new TagService() diff --git a/packages/business/src/whatsapp-flow/index.ts b/packages/business/src/whatsapp-flow/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/whatsapp-flow/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/whatsapp-flow/service.ts b/packages/business/src/whatsapp-flow/service.ts new file mode 100644 index 0000000000..68ed372ea9 --- /dev/null +++ b/packages/business/src/whatsapp-flow/service.ts @@ -0,0 +1,122 @@ +import { + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" +import { whatsappFlowModel } from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +/** WhatsApp's flow shape, as returned by `listFlows`. */ +export type MetaWhatsappFlow = { + id: string + name: string + status: string + categories: unknown + validation_errors: unknown +} + +class WhatsappFlowService extends BaseService { + list(props: { + tx?: DatabaseClient + where: { + workspaceId: string + inboxId?: string + integrationWhatsappId?: string + } + }) { + const { tx = db, where } = props + + const queryWhere = { + integrationWhatsappId: where.integrationWhatsappId, + integrationWhatsapp: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + } + + return tx.query.whatsappFlowModel.findMany({ + where: queryWhere, + with: { + integrationWhatsapp: true, + }, + orderBy: { createdAt: "asc" }, + }) + } + + findByIdUnscoped(id: string) { + return findOrFail({ + table: whatsappFlowModel, + where: { id }, + message: "Whatsapp flow not found", + }) + } + + async syncFromMeta(props: { + integrationWhatsappId: string + flows: MetaWhatsappFlow[] + }): Promise { + await db.transaction(async (tx) => { + const existingFlows = await tx + .select({ + id: whatsappFlowModel.id, + sourceId: whatsappFlowModel.sourceId, + }) + .from(whatsappFlowModel) + .where( + eq( + whatsappFlowModel.integrationWhatsappId, + props.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set(props.flows.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 props.flows) { + 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: props.integrationWhatsappId, + sourceId: flow.id, + status: flow.status, + categories: flow.categories, + validationErrors: flow.validation_errors, + completedCount: "0", + }, + ]) + } + } + }) + } +} + +export const whatsappFlowService = new WhatsappFlowService() diff --git a/packages/business/src/whatsapp-message-template/index.ts b/packages/business/src/whatsapp-message-template/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/whatsapp-message-template/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/whatsapp-message-template/service.ts b/packages/business/src/whatsapp-message-template/service.ts new file mode 100644 index 0000000000..f38c93b47e --- /dev/null +++ b/packages/business/src/whatsapp-message-template/service.ts @@ -0,0 +1,137 @@ +import { + type DatabaseClient, + db, + eq, + inArray, +} from "@chatbotx.io/database/client" +import type { WhatsappTemplateStatus } from "@chatbotx.io/database/partials" +import { whatsappMessageTemplateModel } from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +/** WhatsApp's message-template shape, as returned by `listMessageTemplates`. */ +export type MetaWhatsappTemplate = { + id: string + name: string + language: string + category: string + status: string + components: unknown +} + +class WhatsappMessageTemplateService extends BaseService { + list(props: { + tx?: DatabaseClient + where: { + workspaceId: string + inboxId?: string + integrationWhatsappId?: string + status?: WhatsappTemplateStatus + } + }) { + const { tx = db, where } = props + + const queryWhere = { + integrationWhatsappId: where.integrationWhatsappId, + integrationWhatsapp: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + } + + return tx.query.whatsappMessageTemplateModel.findMany({ + where: queryWhere, + with: { + integrationWhatsapp: true, + }, + orderBy: { createdAt: "asc" }, + }) + } + + /** Approved-only template lookup for outbound template sends, scoped by workspace through the integration relation. */ + findApprovedByIdForIntegration(props: { + id: string + integrationWhatsappId: string + workspaceId: string + }) { + return db.query.whatsappMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationWhatsappId: props.integrationWhatsappId, + integrationWhatsapp: { workspaceId: props.workspaceId }, + status: "APPROVED", + }, + }) + } + + /** Full sync only — WhatsApp's template sync has no partial-match mode. */ + async syncFromMeta(props: { + integrationWhatsappId: string + templates: MetaWhatsappTemplate[] + }): Promise { + await db.transaction(async (tx) => { + const existingTemplates = await tx + .select({ + id: whatsappMessageTemplateModel.id, + sourceId: whatsappMessageTemplateModel.sourceId, + }) + .from(whatsappMessageTemplateModel) + .where( + eq( + whatsappMessageTemplateModel.integrationWhatsappId, + props.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set(props.templates.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 props.templates) { + 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: props.integrationWhatsappId, + language: template.language, + category: template.category, + status: template.status, + sourceId: template.id, + components: template.components, + }, + ]) + } + } + }) + } +} + +export const whatsappMessageTemplateService = + new WhatsappMessageTemplateService() diff --git a/packages/business/src/workspace-member/service.ts b/packages/business/src/workspace-member/service.ts index b00e1ceed6..70b5f94781 100644 --- a/packages/business/src/workspace-member/service.ts +++ b/packages/business/src/workspace-member/service.ts @@ -251,6 +251,25 @@ export class WorkspaceMemberService extends BaseService { }, }) } + + /** + * Bulk membership validation for round-robin allocation — returns just the + * user ids from `userIds` that are actually members of the workspace. + */ + async listExistingUserIds(props: { + workspaceId: string + userIds: string[] + tx?: DatabaseClient + }): Promise<{ userId: string }[]> { + const { workspaceId, userIds, tx = db } = props + if (userIds.length === 0) { + return [] + } + return await tx.query.workspaceMemberModel.findMany({ + where: { workspaceId, userId: { in: userIds } }, + columns: { userId: true }, + }) + } } export const workspaceMemberService = new WorkspaceMemberService() diff --git a/packages/database/__tests__/coexist-run-recovery-repository.test.ts b/packages/database/__tests__/coexist-run-recovery-repository.test.ts index 778dcbace7..7fa95a6dc0 100644 --- a/packages/database/__tests__/coexist-run-recovery-repository.test.ts +++ b/packages/database/__tests__/coexist-run-recovery-repository.test.ts @@ -364,14 +364,14 @@ describe("CoexistSyncRunRepository recovery queries", () => { // F5/M3: one claim for every channel — `fromStatuses` is the only // difference (WhatsApp may claim a run parked in `waiting`). - test("claimRun mints a fresh ownership token and returns the claimed row", async () => { + test("claimRunWithNewToken mints a fresh ownership token and returns the claimed row", async () => { const { tx, set } = wireUpdate([ { id: "run-1", claimToken: "generated" } as never, ]) const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ runId: "run-1", tx }), + repository.claimRunWithNewToken({ runId: "run-1", tx }), ).resolves.toEqual({ id: "run-1", claimToken: "generated" }) const written = set.mock.calls[0]?.[0] as Record @@ -387,7 +387,7 @@ describe("CoexistSyncRunRepository recovery queries", () => { const tokens: unknown[] = [] for (let i = 0; i < 2; i += 1) { const { tx, set } = wireUpdate([{ id: "run-1" }]) - await repository.claimRun({ runId: "run-1", tx }) + await repository.claimRunWithNewToken({ runId: "run-1", tx }) tokens.push( (set.mock.calls[0]?.[0] as Record).claimToken, ) @@ -396,11 +396,11 @@ describe("CoexistSyncRunRepository recovery queries", () => { expect(new Set(tokens).size).toBe(2) }) - test("claimRun widens to the live statuses when asked (WhatsApp)", async () => { + test("claimRunWithNewToken widens to the live statuses when asked (WhatsApp)", async () => { const { tx } = wireUpdate([{ id: "run-1" }]) const repository = new CoexistSyncRunRepository() - await repository.claimRun({ + await repository.claimRunWithNewToken({ runId: "run-1", fromStatuses: LIVE_RUN_STATUSES, tx, @@ -413,12 +413,12 @@ describe("CoexistSyncRunRepository recovery queries", () => { ]) }) - test("claimRun returns null when another worker holds the run", async () => { + test("claimRunWithNewToken returns null when another worker holds the run", async () => { const { tx } = wireUpdate([]) const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ runId: "run-1", tx }), + repository.claimRunWithNewToken({ runId: "run-1", tx }), ).resolves.toBeNull() }) }) diff --git a/packages/database/__tests__/coexist-sync-run-repository.test.ts b/packages/database/__tests__/coexist-sync-run-repository.test.ts index 1c65d6e966..38058d0868 100644 --- a/packages/database/__tests__/coexist-sync-run-repository.test.ts +++ b/packages/database/__tests__/coexist-sync-run-repository.test.ts @@ -39,6 +39,11 @@ vi.mock("../src/schema", () => ({ attempts: "attempts", integrationId: "integrationId", channel: "channel", + currentScan: "currentScan", + importedContactCount: "importedContactCount", + importedMessageCount: "importedMessageCount", + skippedCount: "skippedCount", + failedCount: "failedCount", }, integrationInstagramModel: { id: "instagramId", @@ -65,7 +70,7 @@ describe("CoexistSyncRunRepository", () => { mocks.isUniqueViolationError.mockReturnValue(false) }) - test("claimRun only claims active init/running runs", async () => { + test("claimRunWithNewToken only claims active init/running runs", async () => { const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) const where = vi.fn(() => ({ returning })) const set = vi.fn(() => ({ where })) @@ -73,7 +78,7 @@ describe("CoexistSyncRunRepository", () => { const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ + repository.claimRunWithNewToken({ runId: "run-1", tx: { update } as never, }), @@ -103,7 +108,7 @@ describe("CoexistSyncRunRepository", () => { // Attempts alone used to be enough, so a healthy multi-hour backfill that // burned its retries was terminalized mid-import — taking its pending - // media patches with it. The same 10-minute staleness `claimRun` uses now + // media patches with it. The same 10-minute staleness `claimRunWithNewToken` uses now // gates it, so only a run nobody is driving can be failed. expect(mocks.isNull).toHaveBeenCalledWith("lastHeartbeatAt") expect(mocks.lt).toHaveBeenCalledWith("lastHeartbeatAt", expect.anything()) @@ -235,4 +240,121 @@ describe("CoexistSyncRunRepository", () => { channel: "instagram", }) }) + + // --- Regression guards for the worker data-access refactor ------------- + // `reclaimRunForRetry` is deliberately NOT `claimRunWithNewToken`: the coexist sync claim + // omits the `status IN ('init','running')` filter so a retry can reclaim a + // `failed`/`partial` run. Re-adding that filter silently breaks retry + // recovery, so assert its absence explicitly. + + test("reclaimRunForRetry does NOT filter status IN ('init','running')", async () => { + const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + const repository = new CoexistSyncRunRepository() + + await expect( + repository.reclaimRunForRetry({ + runId: "run-1", + touchUpdatedAt: true, + tx: { update } as never, + }), + ).resolves.toEqual({ id: "run-1" }) + + expect(mocks.inArray).not.toHaveBeenCalled() + // The stale-heartbeat fallback is the whole point of the claim: either the + // run is not currently running, or its heartbeat has gone stale. + expect(mocks.ne).toHaveBeenCalledWith("status", "running") + expect(mocks.lt).toHaveBeenCalledWith("lastHeartbeatAt", expect.anything()) + expect(where).toHaveBeenCalledWith( + expect.objectContaining({ + and: expect.arrayContaining([{ eq: ["runId", "run-1"] }]), + }), + ) + }) + + test("reclaimRunForRetry touches updatedAt only when asked (messenger-sync yes, whatsapp-flush no)", async () => { + const repository = new CoexistSyncRunRepository() + + const makeTx = () => { + const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + return { set, tx: { update: vi.fn(() => ({ set })) } as never } + } + + const touched = makeTx() + await repository.reclaimRunForRetry({ + runId: "run-1", + touchUpdatedAt: true, + tx: touched.tx, + }) + expect(touched.set.mock.calls[0]?.[0]).toHaveProperty("updatedAt") + + const untouched = makeTx() + await repository.reclaimRunForRetry({ + runId: "run-1", + touchUpdatedAt: false, + tx: untouched.tx, + }) + expect(untouched.set.mock.calls[0]?.[0]).not.toHaveProperty("updatedAt") + }) + + test("incrementProgress uses an atomic `col + N` expression, never a read-modify-write", async () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + const select = vi.fn() + const findFirst = vi.fn() + const repository = new CoexistSyncRunRepository() + + await repository.incrementProgress({ + runId: "run-1", + increments: { importedMessageCount: 5, skippedCount: 2 }, + fields: { currentStep: "importing" }, + tx: { + update, + select, + query: { coexistSyncRunModel: { findFirst } }, + } as never, + }) + + // No prior read: a read-modify-write would reintroduce a lost update + // across the two concurrent coexist phase workers. + expect(select).not.toHaveBeenCalled() + expect(findFirst).not.toHaveBeenCalled() + + const setArg = set.mock.calls[0]?.[0] as Record + // Each counter is a `sql` template of the form ` + `. + expect(setArg.importedMessageCount).toEqual({ + sql: [expect.anything(), ["importedMessageCount", 5]], + }) + expect(setArg.skippedCount).toEqual({ + sql: [expect.anything(), ["skippedCount", 2]], + }) + const [strings] = ( + setArg.importedMessageCount as { sql: [string[], unknown[]] } + ).sql + expect(strings.join("")).toContain("+") + // Plain-value fields ride along untouched. + expect(setArg.currentStep).toBe("importing") + expect(where).toHaveBeenCalledWith({ eq: ["runId", "run-1"] }) + }) + + test("incrementProgress skips counters whose increment is undefined", async () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const repository = new CoexistSyncRunRepository() + + await repository.incrementProgress({ + runId: "run-1", + increments: { currentScan: 1, failedCount: undefined }, + tx: { update: vi.fn(() => ({ set })) } as never, + }) + + const setArg = set.mock.calls[0]?.[0] as Record + expect(setArg).toHaveProperty("currentScan") + expect(setArg).not.toHaveProperty("failedCount") + }) }) diff --git a/packages/database/src/repositories/coexist-sync-run/repository.ts b/packages/database/src/repositories/coexist-sync-run/repository.ts index 2221952296..718561676c 100644 --- a/packages/database/src/repositories/coexist-sync-run/repository.ts +++ b/packages/database/src/repositories/coexist-sync-run/repository.ts @@ -52,7 +52,7 @@ export const LIVE_RUN_STATUSES: CoexistRunStatus[] = [ * Statuses the pull channels (Messenger/Instagram) claim from. They never enter * `waiting`, so their claim window is narrower than `LIVE_RUN_STATUSES` — the * one difference between the two claim call sites, expressed as data rather - * than as a second `claimRun` implementation. + * than as a second `claimRunWithNewToken` implementation. */ export const PULL_CLAIMABLE_STATUSES: CoexistRunStatus[] = ["init", "running"] @@ -315,7 +315,7 @@ export class CoexistSyncRunRepository { * * @returns the claimed row, or null when this worker did not win it. */ - async claimRun(input: { + async claimRunWithNewToken(input: { runId: string fromStatuses?: CoexistRunStatus[] tx?: DatabaseClient @@ -353,7 +353,7 @@ export class CoexistSyncRunRepository { /** * Terminalizes runs the scheduler has retried to exhaustion. * - * Gated on the same 10-minute staleness `claimRun` uses: a run being driven + * Gated on the same 10-minute staleness `claimRunWithNewToken` uses: a run being driven * right now heartbeats every batch, and a healthy multi-hour backfill that * happens to have burned its attempts must not be killed mid-import — that * would strand its `pendingPatches` along with it. Only a run nobody has @@ -695,6 +695,140 @@ export class CoexistSyncRunRepository { aiReadsSyncedHistory: input.aiReadsSyncedHistory, }) } + + /** Read `lastSyncedAt` for phase resume. */ + async findLastSyncedAt(input: { + runId: string + tx?: DatabaseClient + }): Promise<{ lastSyncedAt: Date | null } | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { lastSyncedAt: true }, + })) ?? null + ) + } + + /** + * Atomically increments the given counters (`sql\`col + N\`` — NOT a + * read-modify-write, which would reintroduce a lost-update race across the + * two concurrent phase workers) while also setting the given plain-value + * fields. + */ + async incrementProgress(input: { + runId: string + increments: Partial< + Record< + | "currentScan" + | "importedContactCount" + | "importedMessageCount" + | "skippedCount" + | "failedCount", + number + > + > + fields?: CoexistRunProgressInput["fields"] + tx?: DatabaseClient + }): Promise { + const { tx = db, runId, increments, fields } = input + const incrementSet: Record = {} + for (const [key, amount] of Object.entries(increments)) { + if (amount === undefined) { + continue + } + const column = + coexistSyncRunModel[key as keyof typeof coexistSyncRunModel] + incrementSet[key] = sql`${column} + ${amount}` + } + + await tx + .update(coexistSyncRunModel) + .set({ ...incrementSet, ...fields, updatedAt: new Date() }) + .where(eq(coexistSyncRunModel.id, runId)) + } + + /** Init-row read (attempts/currentError/messengerSyncPhase) before claim. */ + async findInitState(input: { + runId: string + tx?: DatabaseClient + }): Promise | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { + attempts: true, + currentError: true, + messengerSyncPhase: true, + }, + })) ?? null + ) + } + + /** + * Optimistic claim with a stale-heartbeat fallback, used by + * `messenger-sync.ts` and `whatsapp-flush.ts`. Deliberately does NOT + * include `claimRunWithNewToken`'s `inArray(status, ["init","running"])` guard — both + * callers reclaim `failed`/`partial` runs on retry, so adding that guard + * would silently break retry recovery. `touchUpdatedAt` distinguishes the + * two callers' SET clauses (messenger-sync also bumps `updatedAt`; + * whatsapp-flush does not) — do not unify beyond this flag. + */ + async reclaimRunForRetry(input: { + runId: string + touchUpdatedAt: boolean + tx?: DatabaseClient + }): Promise { + const { tx = db, runId, touchUpdatedAt } = input + const [run] = await tx + .update(coexistSyncRunModel) + .set({ + status: "running", + startedAt: sql`COALESCE(${coexistSyncRunModel.startedAt}, NOW())`, + lastHeartbeatAt: new Date(), + ...(touchUpdatedAt ? { updatedAt: new Date() } : {}), + }) + .where( + and( + eq(coexistSyncRunModel.id, runId), + or( + ne(coexistSyncRunModel.status, "running"), + lt( + coexistSyncRunModel.lastHeartbeatAt, + sql`NOW() - INTERVAL '10 minutes'`, + ), + ), + ), + ) + .returning() + + return run ?? null + } + + /** Terminal-status derivation counters (importedMessages/skipped/failed). */ + async findTerminalCounters(input: { + runId: string + tx?: DatabaseClient + }): Promise | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { + importedMessageCount: true, + skippedCount: true, + failedCount: true, + }, + })) ?? null + ) + } } export const coexistSyncRunRepository = new CoexistSyncRunRepository() diff --git a/packages/database/src/repositories/contact-inbox/repository.ts b/packages/database/src/repositories/contact-inbox/repository.ts index c6578f6e8d..c09c63c9fd 100644 --- a/packages/database/src/repositories/contact-inbox/repository.ts +++ b/packages/database/src/repositories/contact-inbox/repository.ts @@ -20,7 +20,11 @@ import { integrationMessengerModel, integrationWhatsappModel, } from "../../schema" -import type { ContactInboxModel } from "../../types" +import type { + ContactInboxModel, + ContactModel, + ConversationModel, +} from "../../types" export type WhatsappCtwaInboxRow = { contactInboxId: string @@ -449,4 +453,87 @@ export const contactInboxRepository = { return perChannelRows.flat() }, + + /** + * Resolve a contact inbox with its `conversation` + `contact` relations, + * by an arbitrary `where` (e.g. `{ inboxId, sourceId }` or + * `{ inboxId, sourceUserId }`) — used by `message-status.ts`'s + * `resolveStatusContactInbox` behind `resolveWithSourceUserIdFallback`. + * Keep the caller's probe order/spread exactly as-is; this repo method + * only executes one shape of the query. + */ + findWithConversationAndContact( + props: { where: Record }, + tx: DatabaseClient = db, + ): Promise< + | (ContactInboxModel & { + conversation: ConversationModel | null + contact: ContactModel + }) + | undefined + > { + return tx.query.contactInboxModel.findFirst({ + where: props.where, + with: { conversation: true, contact: true }, + }) + }, + + /** + * Resolve a contact inbox with its `contact` relation, by an arbitrary + * `where` — used by `received-message.ts`'s `resolveExistingContactInbox` + * behind `resolveWithSourceUserIdFallback`. Keep the caller's + * `{ inboxId, channel, ...where }` spread and probe order exactly as-is. + */ + findWithContact( + props: { where: Record }, + tx: DatabaseClient = db, + ): Promise<(ContactInboxModel & { contact: ContactModel }) | undefined> { + return tx.query.contactInboxModel.findFirst({ + where: props.where, + with: { contact: true }, + }) + }, + + /** + * Resolve `{ id, contactId }` for contact inboxes matching an inbox + + * source-id list — used by `inbox_labels/sync.ts`'s `findInboxes` to map + * external label event user ids to local contacts. + */ + listIdsByInboxAndSourceIds( + props: { inboxId: string; sourceIds: string[] }, + tx: DatabaseClient = db, + ): Promise[]> { + return tx.query.contactInboxModel.findMany({ + where: { inboxId: props.inboxId, sourceId: { in: props.sourceIds } }, + columns: { id: true, contactId: true }, + }) + }, + + /** + * Map `sourceId → { id, lastIncomingMessageAt, createdAt }` for an inbox — + * used by `coexist/whatsapp-flush.ts` to resolve identity columns for a + * batch of staged contacts. + */ + listIdentityColumnsByInboxAndSourceIds( + props: { inboxId: string; sourceIds: string[] }, + tx: DatabaseClient = db, + ): Promise< + Pick< + ContactInboxModel, + "id" | "sourceId" | "lastIncomingMessageAt" | "createdAt" + >[] + > { + if (props.sourceIds.length === 0) { + return Promise.resolve([]) + } + return tx.query.contactInboxModel.findMany({ + where: { inboxId: props.inboxId, sourceId: { in: props.sourceIds } }, + columns: { + id: true, + sourceId: true, + lastIncomingMessageAt: true, + createdAt: true, + }, + }) + }, } diff --git a/packages/database/src/repositories/contact/repository.ts b/packages/database/src/repositories/contact/repository.ts index 6eabfaf79f..1968be3bb4 100644 --- a/packages/database/src/repositories/contact/repository.ts +++ b/packages/database/src/repositories/contact/repository.ts @@ -3,6 +3,7 @@ import { countWithRelationsFilterCapped, type DatabaseClient, db, + sql, } from "../../client" import { contactModel } from "../../schema" import { buildContactListWhere, resolveContactOrderBy } from "./list-where" @@ -120,4 +121,32 @@ export const contactRepository = { 0, ) }, + /** + * Fill `Contact.phoneNumber` / `Contact.email` only when currently NULL — + * moved VERBATIM from `bulk-historical-import.ts`'s contact-enrichment + * transaction. The double-`::text` cast and the compound `WHERE` guard are + * load-bearing; do not simplify. + */ + async enrichIfNull( + props: { + contactId: string + phoneNumber?: string + email?: string + }, + tx: DatabaseClient = db, + ): Promise { + const { contactId, phoneNumber, email } = props + await tx.transaction(async (innerTx) => { + await innerTx.execute(sql` + UPDATE "Contact" SET + "phoneNumber" = COALESCE("phoneNumber", ${phoneNumber ?? null}::text), + "email" = COALESCE("email", ${email ?? null}::text) + WHERE "id" = ${contactId} + AND ( + (${phoneNumber ?? null}::text IS NOT NULL AND "phoneNumber" IS NULL) + OR (${email ?? null}::text IS NOT NULL AND "email" IS NULL) + ) + `) + }) + }, } diff --git a/packages/database/src/repositories/import/index.ts b/packages/database/src/repositories/import/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/import/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/import/repository.ts b/packages/database/src/repositories/import/repository.ts new file mode 100644 index 0000000000..78fad56f70 --- /dev/null +++ b/packages/database/src/repositories/import/repository.ts @@ -0,0 +1,26 @@ +import { type DatabaseClient, db } from "../../client" + +/** + * Single-purpose repository for resolving an `Import`'s owning workspace by + * id. Runs inside the blocked-owner-guard fast path + * (`apps/worker/src/lib/resolve-workspace-id.ts`) — must stay one indexed + * primary-key read projecting only `workspaceId`, and must never throw (the + * guard treats `undefined` as fail-open, per AGENTS.md invariant 15). + * + * Follow-up: the `AI_WORKSPACE_SCOPES` union in + * `ai-workspace-scope/repository.ts` is close to a general "resolve + * workspace by record id" registry; folding `importId` into a renamed + * `workspaceScopeRepository` would remove this one-off repository. + */ +export const importRepository = { + async findWorkspaceId( + props: { id: string }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.importModel.findFirst({ + where: { id: props.id }, + columns: { workspaceId: true }, + }) + return row?.workspaceId + }, +} diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index 7b4cac7506..e73d941105 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -18,10 +18,12 @@ export * from "./conversation-ai-context" export * from "./coupon" export * from "./error-log" export * from "./file" +export * from "./import" export * from "./inbox" export * from "./integration-api" export * from "./integration-facebook-ads" export * from "./integration-instagram" +export * from "./integration-lookup" export * from "./integration-messenger" export * from "./integration-whatsapp" export * from "./media-library-file" diff --git a/packages/database/src/repositories/integration-lookup/index.ts b/packages/database/src/repositories/integration-lookup/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/integration-lookup/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/integration-lookup/repository.ts b/packages/database/src/repositories/integration-lookup/repository.ts new file mode 100644 index 0000000000..bc3c96027b --- /dev/null +++ b/packages/database/src/repositories/integration-lookup/repository.ts @@ -0,0 +1,44 @@ +import { type DatabaseClient, db, sql } from "../../client" + +/** + * Raw row shape for a dynamic per-channel integration table lookup. Callers + * (worker `IntegrationRow`) narrow `auth` to their own `AuthValue` type — + * this repository stays free of the `@chatbotx.io/sdk` dependency that + * `packages/database` does not carry. + */ +export type IntegrationLookupRow = { + id: string + auth: unknown + workspaceId?: string + inboxId: string + [x: string]: unknown +} + +/** + * Dynamic-table integration lookups keyed on an external identifier or an + * inbox id. `modelName`/`columnName`/`integrationTable` are always the + * output of an exhaustive `switch` in the caller (never raw user input) — + * `sql.identifier()` is the injection guard here and MUST be kept on both + * queries. + */ +export const integrationLookupRepository = { + async findAuthByIdentifier( + props: { modelName: string; columnName: string; identifier: string }, + tx: DatabaseClient = db, + ): Promise { + const result = await tx.execute( + sql`SELECT * FROM ${sql.identifier(props.modelName)} WHERE ${sql.identifier(props.columnName)} = ${props.identifier} LIMIT 1`, + ) + return result.rows[0] + }, + + async findAuthByInboxId( + props: { modelName: string; inboxId: string }, + tx: DatabaseClient = db, + ): Promise { + const result = await tx.execute( + sql`SELECT * FROM ${sql.identifier(props.modelName)} WHERE "inboxId" = ${props.inboxId} LIMIT 1`, + ) + return result.rows[0] + }, +} diff --git a/packages/database/src/repositories/integration-messenger/repository.ts b/packages/database/src/repositories/integration-messenger/repository.ts index 55649ce616..40c4fc0a18 100644 --- a/packages/database/src/repositories/integration-messenger/repository.ts +++ b/packages/database/src/repositories/integration-messenger/repository.ts @@ -327,4 +327,33 @@ export const integrationMessengerRepository = { return row ?? null }, + + /** + * Load a Messenger integration by id with NO workspace scope. Callers that + * have an id sourced from a workspace-scoped record elsewhere (e.g. a + * coexist sync run) must independently compare `workspaceId` themselves — + * do not treat this as a substitute for a workspace-scoped lookup. + */ + findById( + props: { id: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationMessengerModel.findFirst({ + where: { id: props.id }, + }) + }, + + /** + * Load a Messenger integration by Facebook page id with NO workspace scope + * — used by inbound webhooks (e.g. inbox-label sync) that only have the + * page id and have not yet resolved a workspace. + */ + findByPageIdUnscoped( + props: { pageId: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationMessengerModel.findFirst({ + where: { pageId: props.pageId }, + }) + }, } diff --git a/packages/database/src/repositories/integration-whatsapp/repository.ts b/packages/database/src/repositories/integration-whatsapp/repository.ts index 9fe242344c..12f17c0eb7 100644 --- a/packages/database/src/repositories/integration-whatsapp/repository.ts +++ b/packages/database/src/repositories/integration-whatsapp/repository.ts @@ -195,6 +195,21 @@ class IntegrationWhatsappRepository { .where(eq(integrationWhatsappModel.id, id)) } + /** + * No workspace scope — called from the inbound webhook-verification handler + * before a workspace context is resolved. + */ + async updateAuthUnscoped( + id: string, + auth: Record, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(integrationWhatsappModel) + .set({ auth }) + .where(eq(integrationWhatsappModel.id, id)) + } + /** * Resolves the WhatsApp integration that owns a given `Inbox.id`. Ads * conversion trigger hook points (tag applied, keyword matched, contact diff --git a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts index 3d03caa36c..278389146b 100644 --- a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts +++ b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts @@ -41,6 +41,22 @@ const pendingFilter = (phoneNumberId: string) => ) export const whatsappCoexistStagingRepository = { + /** Idempotent staging insert keyed on `(phoneNumberId, payloadHash)` — keep untargeted `onConflictDoNothing()`. */ + async stagePayload( + props: { + id: string + phoneNumberId: string + payload: unknown + payloadHash: string + }, + tx: DatabaseClient = db, + ): Promise { + await tx + .insert(whatsappCoexistStagingModel) + .values(props) + .onConflictDoNothing() + }, + /** * Oldest-first page of staging rows still awaiting import for one phone * number. Always bounded by `limit` — the flush drains in chunks, and the diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index d1c0f0d0e6..04f1ddcc20 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -167,6 +167,8 @@ export type IntegrationInstagramModel = typeof schema.integrationInstagramModel.$inferSelect export type WhatsappMessageTemplateModel = typeof schema.whatsappMessageTemplateModel.$inferSelect +export type MessengerMessageTemplateModel = + typeof schema.messengerMessageTemplateModel.$inferSelect export type WhatsappFlowModel = typeof schema.whatsappFlowModel.$inferSelect export type FlowAnalyticsSessionModel = typeof schema.flowAnalyticsSessionModel.$inferSelect diff --git a/packages/sequence-scheduler/src/sequence-dispatch.ts b/packages/sequence-scheduler/src/sequence-dispatch.ts index 79ae6b7f7c..264677d75d 100644 --- a/packages/sequence-scheduler/src/sequence-dispatch.ts +++ b/packages/sequence-scheduler/src/sequence-dispatch.ts @@ -60,6 +60,105 @@ export const sequenceDispatchUtils = { bucket: d.bucket, })) }, + + /** Load a running dispatch — moved verbatim from `sequence-flow.ts` `fetchDispatch`. */ + findRunning: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + }) => { + const { dbClient, dispatchId, workspaceId } = props + return await dbClient.query.sequenceDispatchModel.findFirst({ + where: { + id: dispatchId, + workspaceId, + status: "running", + }, + }) + }, + + /** + * Mark a dispatch completed — moved verbatim from `sequence-flow.ts` + * `markDispatchCompleted`, keeping its `status = 'running'` guard + * (idempotency guard for job retries). + */ + markCompleted: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + sentAt: Date + }): Promise => { + const { dbClient, dispatchId, workspaceId, sentAt } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "completed", + completedAt: sentAt, + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, + + /** + * Mark a dispatch canceled — keeps the `status = 'running'` guard + * (idempotency guard for job retries). + */ + markCanceled: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + reason: string + }): Promise => { + const { dbClient, dispatchId, workspaceId, reason } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "canceled", + lastError: reason, + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, + + /** + * Mark a dispatch failed — keeps the `status = 'running'` guard + * (idempotency guard for job retries). + */ + markFailed: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + errorMessage: string + }): Promise => { + const { dbClient, dispatchId, workspaceId, errorMessage } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "failed", + lastError: errorMessage, + failedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, } export type SequenceDispatchUtils = typeof sequenceDispatchUtils