diff --git a/apps/builder/__tests__/broadcasts-public-api.test.ts b/apps/builder/__tests__/broadcasts-public-api.test.ts index 24076c52da..1aa6b54d61 100644 --- a/apps/builder/__tests__/broadcasts-public-api.test.ts +++ b/apps/builder/__tests__/broadcasts-public-api.test.ts @@ -57,8 +57,11 @@ const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) const broadcastService = { + list: vi.fn(), + listAudience: vi.fn(), findByIdOrName: vi.fn(), listExistingIds: vi.fn(), + listContactsPage: vi.fn(), create: vi.fn(), update: vi.fn(), updateDraft: vi.fn(), @@ -69,19 +72,9 @@ const broadcastService = { resendWithPruning: vi.fn(), softDeleteBroadcasts: vi.fn(), } -const contactInboxService = { findManyByIds: vi.fn() } vi.mock("@chatbotx.io/business", () => ({ broadcastService, - contactInboxService, -})) - -const broadcastAnalyticsService = { getContacts: vi.fn() } -vi.mock("@chatbotx.io/analytics", () => ({ broadcastAnalyticsService })) - -vi.mock("../src/features/broadcasts/queries", () => ({ - listBroadcasts: vi.fn(), - listBroadcastAudience: vi.fn(), })) await import("@/features/broadcasts/api/public") @@ -336,161 +329,57 @@ describe("DELETE /v1/broadcasts/{id}", () => { describe("GET /v1/broadcasts/{id}/contacts", () => { const procedure = findProcedure("GET", "/v1/broadcasts/{id}/contacts") - test("404s when the broadcast does not exist in this workspace", async () => { - broadcastService.listExistingIds.mockResolvedValueOnce([]) + // The existence check, analytics/contact-inbox joins, and row shaping now + // live in `broadcastService.listContactsPage` (shared with the private + // route) — see `packages/business/__tests__` for coverage of that + // orchestration. This route's job is just to call it and pass the result + // through; `conversationId` is a superset the public response schema + // doesn't declare, so the handler returns it unfiltered and zod strips it. + test("propagates a not-found rejection from the service", async () => { + broadcastService.listContactsPage.mockRejectedValueOnce( + new Error("Broadcast not found"), + ) await expect( procedure.handler?.({ context: { workspace: { id: "ws-1" } }, input: { id: "b-1", eventType: "message:sent", page: 1, perPage: 20 }, }), - ).rejects.toThrow() - - expect(broadcastAnalyticsService.getContacts).not.toHaveBeenCalled() + ).rejects.toThrow("Broadcast not found") }) - test("returns an empty page without a contact-inbox lookup when there are no matching recipients", async () => { - broadcastService.listExistingIds.mockResolvedValueOnce(["b-1"]) - broadcastAnalyticsService.getContacts.mockResolvedValueOnce({ - contactInboxIds: [], - contactEventMap: new Map(), - total: 0, - }) - - const result = await procedure.handler?.({ - context: { workspace: { id: "ws-1" } }, - input: { id: "b-1", eventType: "message:sent", page: 1, perPage: 20 }, - }) - - expect(result).toEqual({ data: [], pageCount: 0 }) - expect(contactInboxService.findManyByIds).not.toHaveBeenCalled() - }) - - test("joins recipient events with contact-inbox details", async () => { - broadcastService.listExistingIds.mockResolvedValueOnce(["b-1"]) - broadcastAnalyticsService.getContacts.mockResolvedValueOnce({ - contactInboxIds: ["ci-1"], - contactEventMap: new Map([ - [ - "ci-1", - { - contactId: "contact-1", - occurredAt: "2026-01-01T00:00:00.000Z", - errorContent: null, - }, - ], - ]), + test("scopes the lookup to the token's workspace and returns the service result", async () => { + const row = { + contactId: "contact-1", + contactInboxId: "ci-1", + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + sourceId: "src-1", + avatar: null, + channel: "whatsapp", + errorContent: null, + occurredAt: "2026-01-01T00:00:00.000Z", + conversationId: "conv-1", + } + broadcastService.listContactsPage.mockResolvedValueOnce({ + data: [row], total: 1, - }) - contactInboxService.findManyByIds.mockResolvedValueOnce([ - { - id: "ci-1", - sourceId: "src-1", - channel: "whatsapp", - contact: { - id: "contact-1", - firstName: "Ada", - lastName: "Lovelace", - fullName: "Ada Lovelace", - avatar: null, - }, - }, - ]) - - const result = await procedure.handler?.({ - context: { workspace: { id: "ws-1" } }, - input: { id: "b-1", eventType: "message:sent", page: 1, perPage: 20 }, - }) - - expect(result).toEqual({ - data: [ - { - contactId: "contact-1", - contactInboxId: "ci-1", - firstName: "Ada", - lastName: "Lovelace", - fullName: "Ada Lovelace", - sourceId: "src-1", - avatar: null, - channel: "whatsapp", - errorContent: null, - occurredAt: "2026-01-01T00:00:00.000Z", - }, - ], pageCount: 1, }) - expect(contactInboxService.findManyByIds).toHaveBeenCalledWith({ - workspaceId: "ws-1", - ids: ["ci-1"], - }) - }) - - test("drops a recipient whose contact-inbox no longer resolves, leaving pageCount driven by the DB total", async () => { - broadcastService.listExistingIds.mockResolvedValueOnce(["b-1"]) - broadcastAnalyticsService.getContacts.mockResolvedValueOnce({ - contactInboxIds: ["ci-1", "ci-gone"], - contactEventMap: new Map([ - [ - "ci-1", - { - contactId: "contact-1", - occurredAt: "2026-01-01T00:00:00.000Z", - errorContent: null, - }, - ], - [ - "ci-gone", - { - contactId: "contact-gone", - occurredAt: "2026-01-02T00:00:00.000Z", - errorContent: null, - }, - ], - ]), - total: 2, - }) - // `getContacts` scopes by `Broadcast.workspaceId` while `findManyByIds` - // scopes by `Contact.workspaceId`, so a contact deleted or moved out of - // the workspace after the send is counted in `total` but has no row here. - contactInboxService.findManyByIds.mockResolvedValueOnce([ - { - id: "ci-1", - sourceId: "src-1", - channel: "whatsapp", - contact: { - id: "contact-1", - firstName: "Ada", - lastName: null, - fullName: "Ada", - avatar: null, - }, - }, - ]) const result = await procedure.handler?.({ context: { workspace: { id: "ws-1" } }, input: { id: "b-1", eventType: "message:sent", page: 1, perPage: 20 }, }) - // Unresolvable rows are dropped rather than emitted as nulls, and - // `pageCount` stays anchored to the DB count — so `data.length` can be - // shorter than the total implies. - expect(result).toEqual({ - data: [ - { - contactId: "contact-1", - contactInboxId: "ci-1", - firstName: "Ada", - lastName: null, - fullName: "Ada", - sourceId: "src-1", - avatar: null, - channel: "whatsapp", - errorContent: null, - occurredAt: "2026-01-01T00:00:00.000Z", - }, - ], - pageCount: 1, + expect(broadcastService.listContactsPage).toHaveBeenCalledWith({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, }) + expect(result).toEqual({ data: [row], pageCount: 1 }) }) }) diff --git a/apps/builder/__tests__/broadcasts-public-scope.test.ts b/apps/builder/__tests__/broadcasts-public-scope.test.ts index 475733b56a..7e3d6f8823 100644 --- a/apps/builder/__tests__/broadcasts-public-scope.test.ts +++ b/apps/builder/__tests__/broadcasts-public-scope.test.ts @@ -22,8 +22,11 @@ vi.mock("@chatbotx.io/business", () => ({ userQuotaService: { getAccessState }, quotaEnforcementService: { isAtLimit }, broadcastService: { + list: vi.fn(), + listAudience: vi.fn(), findByIdOrName: vi.fn(), listExistingIds: vi.fn(), + listContactsPage: vi.fn(), create: vi.fn(), update: vi.fn(), updateDraft: vi.fn(), @@ -34,11 +37,6 @@ vi.mock("@chatbotx.io/business", () => ({ resendWithPruning: vi.fn(), softDeleteBroadcasts: vi.fn(), }, - contactInboxService: { findManyByIds: vi.fn() }, -})) - -vi.mock("@chatbotx.io/analytics", () => ({ - broadcastAnalyticsService: { getContacts: vi.fn() }, })) vi.mock("@/lib/log", () => ({ @@ -62,14 +60,6 @@ vi.mock("@/middlewares/auth", () => ({ authMiddleware: vi.fn(), })) -// The broadcasts router's queries hit the database at import time -// (`@chatbotx.io/database/client`); never reached on the FORBIDDEN path this -// test exercises, but the import chain must not try to open a connection. -vi.mock("../src/features/broadcasts/queries", () => ({ - listBroadcasts: vi.fn(), - listBroadcastAudience: vi.fn(), -})) - const { call } = await import("@orpc/server") const { broadcastsPublicRouter } = await import( "../src/features/broadcasts/api/public" @@ -109,10 +99,8 @@ describe("real router: broadcasts public API scope wiring", () => { test("null scopes (unrestricted) passes the real GET /v1/broadcasts route", async () => { findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) - const { listBroadcasts } = await import( - "../src/features/broadcasts/queries" - ) - vi.mocked(listBroadcasts).mockResolvedValue({ + const { broadcastService } = await import("@chatbotx.io/business") + vi.mocked(broadcastService.list).mockResolvedValue({ data: [], pageCount: 1, } as never) @@ -234,31 +222,13 @@ describe("real router: broadcasts public API scope wiring", () => { ) }) - test("listContacts scopes both the broadcast lookup and the contact-inbox lookup to the token's workspace", async () => { - const { broadcastService, contactInboxService } = await import( - "@chatbotx.io/business" - ) - const { broadcastAnalyticsService } = await import( - "@chatbotx.io/analytics" - ) - vi.mocked(broadcastService.listExistingIds).mockResolvedValue([ - "999999", - ] as never) - vi.mocked(broadcastAnalyticsService.getContacts).mockResolvedValue({ - contactInboxIds: ["ci-1"], - contactEventMap: new Map([ - [ - "ci-1", - { - contactId: "contact-1", - occurredAt: "2026-01-01T00:00:00.000Z", - errorContent: null, - }, - ], - ]), - total: 1, + test("listContacts scopes the lookup to the token's workspace", async () => { + const { broadcastService } = await import("@chatbotx.io/business") + vi.mocked(broadcastService.listContactsPage).mockResolvedValue({ + data: [], + total: 0, + pageCount: 0, } as never) - vi.mocked(contactInboxService.findManyByIds).mockResolvedValue([]) await invoke(broadcastsPublicRouter.listContacts, { id: "999999", @@ -267,13 +237,7 @@ describe("real router: broadcasts public API scope wiring", () => { perPage: 20, }) - expect(broadcastService.listExistingIds).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: "ws-1" }), - ) - expect(broadcastAnalyticsService.getContacts).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: "ws-1" }), - ) - expect(contactInboxService.findManyByIds).toHaveBeenCalledWith( + expect(broadcastService.listContactsPage).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "ws-1" }), ) }) diff --git a/apps/builder/__tests__/google-sheets-disconnect-action.test.ts b/apps/builder/__tests__/google-sheets-disconnect-action.test.ts new file mode 100644 index 0000000000..f966d8c26d --- /dev/null +++ b/apps/builder/__tests__/google-sheets-disconnect-action.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + auditRecord: vi.fn(), + disconnect: vi.fn(), + findByWorkspaceIdOrFail: vi.fn(), + loggerError: vi.fn(), + vendorDisconnect: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.action = (fn: unknown) => fn + return { + workspaceActionClientAllowExpired: chain, + } +}) + +vi.mock("@/lib/log", () => ({ + logger: { error: mocks.loggerError }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + integrationGoogleSheetService: { + disconnect: mocks.disconnect, + findByWorkspaceIdOrFail: mocks.findByWorkspaceIdOrFail, + }, +})) + +vi.mock("@chatbotx.io/business/audit", () => ({ + auditService: { record: mocks.auditRecord }, +})) + +vi.mock("@chatbotx.io/integration-google-sheets", () => ({ + integration: { disconnect: mocks.vendorDisconnect }, +})) + +const { disconnectGoogleSheetsAction } = await import( + "../src/features/integration-google-sheets/actions/disconnect.action" +) + +beforeEach(() => { + vi.clearAllMocks() + mocks.findByWorkspaceIdOrFail.mockResolvedValue({ + integrationId: "integration-1", + auth: { accessToken: "token" }, + }) + mocks.disconnect.mockResolvedValue(undefined) +}) + +describe("disconnectGoogleSheetsAction", () => { + test("logs a failing vendor disconnect call but still runs the local disconnect", async () => { + mocks.vendorDisconnect.mockRejectedValue(new Error("vendor down")) + + await ( + disconnectGoogleSheetsAction as (props: unknown) => Promise + )({ bindArgsParsedInputs: ["ws-1"] }) + + expect(mocks.loggerError).toHaveBeenCalledWith( + expect.any(Error), + "Unable to disconnect google sheets for workspace: ws-1", + ) + expect(mocks.disconnect).toHaveBeenCalledWith("integration-1") + expect(mocks.auditRecord).toHaveBeenCalledWith({ + workspaceId: "ws-1", + action: "disconnect", + detail: "disconnected the Google Sheets integration", + }) + }) + + test("runs the local disconnect when the vendor call succeeds", async () => { + mocks.vendorDisconnect.mockResolvedValue(undefined) + + await ( + disconnectGoogleSheetsAction as (props: unknown) => Promise + )({ bindArgsParsedInputs: ["ws-1"] }) + + expect(mocks.loggerError).not.toHaveBeenCalled() + expect(mocks.disconnect).toHaveBeenCalledWith("integration-1") + }) +}) diff --git a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts index 7bf59a785e..e50941d4eb 100644 --- a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts +++ b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts @@ -5,7 +5,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ connect: vi.fn(), findWorkspaceById: vi.fn(), - transaction: vi.fn(), auditRecord: vi.fn(), handleRequest: vi.fn(), redirect: vi.fn(), @@ -31,10 +30,6 @@ vi.mock("@chatbotx.io/business/errors", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mocks.transaction }, -})) - vi.mock("next/navigation", () => ({ redirect: mocks.redirect, })) @@ -60,9 +55,6 @@ describe("connectTiktokHandler", () => { username: "shop_1", }, }) - mocks.transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => - fn({}), - ) }) test("records reconnect audit with the persisted TikTok integration id on conflict", async () => { @@ -79,14 +71,16 @@ describe("connectTiktokHandler", () => { redirectUrl: "https://app.example.com/integrations/tiktok/callback", }) - expect(mocks.connect).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: "workspace-1", - openId: "open-id-1", - username: "shop_1", - displayName: "TikTok Shop", + expect(mocks.connect).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ownerId: "owner-1", + openId: "open-id-1", + username: "shop_1", + displayName: "TikTok Shop", + auth: expect.objectContaining({ + metadata: expect.objectContaining({ openId: "open-id-1" }), }), - ) + }) expect(mocks.auditRecord).toHaveBeenCalledTimes(1) expect(mocks.auditRecord).toHaveBeenCalledWith({ userId: "admin-1", diff --git a/apps/builder/__tests__/sequences-list-step-contacts-api.test.ts b/apps/builder/__tests__/sequences-list-step-contacts-api.test.ts index 496576559b..9a9d6ca7d7 100644 --- a/apps/builder/__tests__/sequences-list-step-contacts-api.test.ts +++ b/apps/builder/__tests__/sequences-list-step-contacts-api.test.ts @@ -44,8 +44,8 @@ const { authorizedAPI, mocks, workspaceAuthorizedMidddleware } = vi.hoisted( return { authorizedAPI: procedure, mocks: { - getContacts: vi.fn(), - findManyByIds: vi.fn(), + getStepStats: vi.fn(), + listStepContactsPage: vi.fn(), state, }, workspaceAuthorizedMidddleware: vi.fn(), @@ -58,13 +58,12 @@ vi.mock("@/middlewares/auth", () => ({ workspaceAuthorizedMidddleware })) vi.mock("@chatbotx.io/analytics", () => ({ sequenceAnalyticsService: { - getStepStats: vi.fn(), - getContacts: mocks.getContacts, + getStepStats: mocks.getStepStats, }, })) -vi.mock("@chatbotx.io/business", () => ({ - contactInboxService: { findManyByIds: mocks.findManyByIds }, +vi.mock("@chatbotx.io/business/sequence", () => ({ + sequenceService: { listStepContactsPage: mocks.listStepContactsPage }, })) const { sequencesPrivateAPI } = await import("@/features/sequences/api/private") @@ -76,44 +75,31 @@ describe("privateListSequenceStepContactsAPI", () => { ) }) - // Regression guard: this route previously emitted the ContactInbox id as - // `contactId` (copied from the analogous, since-fixed bug in broadcasts' - // private route). Every row here feeds `StatsContactsDialog` → - // `addContactTagAction`/`bulkTagStatsContactsAction`, which tags by the - // real Contact id — a ContactInbox id there silently tags the wrong - // contact (or fails to resolve one at all). - test("maps contactId from the event data's Contact id, not the ContactInbox id", async () => { - const contactInboxId = "contact-inbox-1" - const realContactId = "contact-1" - - mocks.getContacts.mockResolvedValue({ - contactInboxIds: [contactInboxId], - contactEventMap: new Map([ - [ - contactInboxId, - { - contactId: realContactId, - errorContent: null, - occurredAt: "2026-01-01T00:00:00.000Z", - }, - ], - ]), - }) - - mocks.findManyByIds.mockResolvedValue([ - { - id: contactInboxId, - sourceId: "source-1", - channel: "whatsapp", - conversation: { id: "conversation-1" }, - contact: { + // The existence check, analytics/contact-inbox joins, and row shaping now + // live in `sequenceService.listStepContactsPage` (shared orchestration — + // see `packages/business/__tests__` for coverage of contactId mapping and + // the conversationId fallback). This route's job is just to call it, + // forward `total` from input, and pass the result through. + test("calls the service with the request params and returns its result", async () => { + mocks.listStepContactsPage.mockResolvedValue({ + data: [ + { + contactId: "contact-1", + contactInboxId: "contact-inbox-1", firstName: "Ada", lastName: "Lovelace", fullName: "Ada Lovelace", + sourceId: "source-1", avatar: null, + channel: "whatsapp", + errorContent: null, + occurredAt: "2026-01-01T00:00:00.000Z", + conversationId: "conversation-1", }, - }, - ]) + ], + total: 1, + pageCount: 1, + }) expect(mocks.state.handler).toBeDefined() const result = await mocks.state.handler?.({ @@ -128,61 +114,57 @@ describe("privateListSequenceStepContactsAPI", () => { }, }) - expect(result?.data).toHaveLength(1) - expect(result?.data[0]).toMatchObject({ - contactId: realContactId, - contactInboxId, - conversationId: "conversation-1", + expect(mocks.listStepContactsPage).toHaveBeenCalledWith({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 1, + page: 1, + perPage: 20, + }) + expect(result).toEqual({ + data: [ + { + contactId: "contact-1", + contactInboxId: "contact-inbox-1", + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + sourceId: "source-1", + avatar: null, + channel: "whatsapp", + errorContent: null, + occurredAt: "2026-01-01T00:00:00.000Z", + conversationId: "conversation-1", + }, + ], + total: 1, + page: 1, + pageCount: 1, }) - expect((result?.data[0] as { contactId: string }).contactId).not.toBe( - contactInboxId, - ) }) - test("drops a contact inbox with no conversation", async () => { - const contactInboxId = "contact-inbox-1" - - mocks.getContacts.mockResolvedValue({ - contactInboxIds: [contactInboxId], - contactEventMap: new Map([ - [ - contactInboxId, - { - contactId: "contact-1", - errorContent: null, - occurredAt: "2026-01-01T00:00:00.000Z", - }, - ], - ]), + test("defaults a missing total to 0 before calling the service", async () => { + mocks.listStepContactsPage.mockResolvedValue({ + data: [], + total: 0, + pageCount: 0, }) - mocks.findManyByIds.mockResolvedValue([ - { - id: contactInboxId, - sourceId: "source-1", - channel: "whatsapp", - conversation: null, - contact: { - firstName: null, - lastName: null, - fullName: null, - avatar: null, - }, - }, - ]) - - const result = await mocks.state.handler?.({ + await mocks.state.handler?.({ input: { workspaceId: "ws-1", sequenceId: "seq-1", stepId: "step-1", eventType: "message:sent", - total: 1, page: 1, perPage: 20, }, }) - expect(result?.data).toHaveLength(0) + expect(mocks.listStepContactsPage).toHaveBeenCalledWith( + expect.objectContaining({ total: 0 }), + ) }) }) diff --git a/apps/builder/__tests__/sequences-public-scope.test.ts b/apps/builder/__tests__/sequences-public-scope.test.ts index 6da2ec6d7d..44cdc6d14c 100644 --- a/apps/builder/__tests__/sequences-public-scope.test.ts +++ b/apps/builder/__tests__/sequences-public-scope.test.ts @@ -25,6 +25,7 @@ vi.mock("@chatbotx.io/business", () => ({ vi.mock("@chatbotx.io/business/sequence", () => ({ sequenceService: { + list: vi.fn(), findWithSteps: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -53,13 +54,6 @@ vi.mock("@/middlewares/auth", () => ({ authMiddleware: vi.fn(), })) -// The sequences router's queries hit the database at import time -// (`@chatbotx.io/database/client`); never reached on the FORBIDDEN path this -// test exercises, but the import chain must not try to open a connection. -vi.mock("../src/features/sequences/queries", () => ({ - listSequences: vi.fn(), -})) - const { call } = await import("@orpc/server") const { sequencesPublicRouter } = await import( "../src/features/sequences/api/public" @@ -99,8 +93,8 @@ describe("real router: sequences public API scope wiring", () => { test("null scopes (unrestricted) passes the real GET /v1/sequences route", async () => { findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) - const { listSequences } = await import("../src/features/sequences/queries") - vi.mocked(listSequences).mockResolvedValue({ + const { sequenceService } = await import("@chatbotx.io/business/sequence") + vi.mocked(sequenceService.list).mockResolvedValue({ data: [], pageCount: 1, } as never) diff --git a/apps/builder/__tests__/smtp-actions-thin.test.ts b/apps/builder/__tests__/smtp-actions-thin.test.ts new file mode 100644 index 0000000000..73590c4747 --- /dev/null +++ b/apps/builder/__tests__/smtp-actions-thin.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + auditRecord: vi.fn(), + connect: vi.fn(), + findByIdForWorkspace: vi.fn(), + findWorkspace: vi.fn(), + isSameJsonValue: vi.fn(), + update: vi.fn(), + verifySmtpConnection: vi.fn(), +})) + +const callOrder: string[] = [] + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (fn: unknown) => fn + return { + workspaceActionClient: chain, + } +}) + +vi.mock("@chatbotx.io/business", () => ({ + integrationSmtpService: { + connect: mocks.connect, + findByIdForWorkspace: mocks.findByIdForWorkspace, + update: mocks.update, + }, + workspaceService: { find: mocks.findWorkspace }, +})) + +vi.mock("@chatbotx.io/business/audit", () => ({ + auditService: { record: mocks.auditRecord }, + isSameJsonValue: mocks.isSameJsonValue, +})) + +vi.mock("../src/features/integration-smtp/lib/verify-connection", () => ({ + verifySmtpConnection: (...args: unknown[]) => { + callOrder.push("verify") + return Promise.resolve(mocks.verifySmtpConnection(...args)) + }, +})) + +const { createSmtpAction } = await import( + "../src/features/integration-smtp/actions/create-smtp.action" +) +const { updateSmtpAction } = await import( + "../src/features/integration-smtp/actions/update-smtp.action" +) + +beforeEach(() => { + vi.clearAllMocks() + callOrder.length = 0 + mocks.findWorkspace.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" }) + mocks.connect.mockImplementation(() => { + callOrder.push("connect") + return Promise.resolve({ inbox: { id: "inbox-1" }, wasCreated: true }) + }) + mocks.findByIdForWorkspace.mockResolvedValue({ + id: "smtp-1", + name: "old-name", + fromAddress: "old@example.com", + auth: { + authType: "custom", + provider: "google", + host: "smtp.gmail.com", + port: 587, + username: "old-user", + password: "old-pass", + }, + }) + mocks.update.mockResolvedValue({ + id: "smtp-1", + name: "new-name", + fromAddress: "new@example.com", + }) +}) + +describe("createSmtpAction", () => { + test("calls verifySmtpConnection before integrationSmtpService.connect", async () => { + await (createSmtpAction as (props: unknown) => Promise)({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { + provider: "google", + host: "ignored.example.com", + port: 25, + username: "user1", + password: "pass1", + fromAddress: "from@example.com", + }, + }) + + expect(callOrder).toEqual(["verify", "connect"]) + }) + + test("a non-other provider passes smtpHostMap-resolved host/port", async () => { + await (createSmtpAction as (props: unknown) => Promise)({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { + provider: "google", + host: "ignored.example.com", + port: 25, + username: "user1", + password: "pass1", + fromAddress: "from@example.com", + }, + }) + + expect(mocks.connect).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + host: "smtp.gmail.com", + port: 587, + }), + }), + ) + }) +}) + +describe("updateSmtpAction", () => { + test("records an audit only when isSameJsonValue reports a change", async () => { + mocks.isSameJsonValue.mockReturnValue(false) + + await (updateSmtpAction as (props: unknown) => Promise)({ + bindArgsParsedInputs: ["ws-1", "smtp-1"], + parsedInput: { + provider: "google", + host: "", + port: 0, + username: "new-user", + password: "new-pass", + fromAddress: "new@example.com", + }, + }) + + expect(mocks.auditRecord).toHaveBeenCalledTimes(1) + + mocks.auditRecord.mockClear() + mocks.isSameJsonValue.mockReturnValue(true) + + await (updateSmtpAction as (props: unknown) => Promise)({ + bindArgsParsedInputs: ["ws-1", "smtp-1"], + parsedInput: { + provider: "google", + host: "", + port: 0, + username: "old-user", + password: "old-pass", + fromAddress: "old@example.com", + }, + }) + + expect(mocks.auditRecord).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/update-webchat-action-permission.test.ts b/apps/builder/__tests__/update-webchat-action-permission.test.ts index 1577619d8d..0656545863 100644 --- a/apps/builder/__tests__/update-webchat-action-permission.test.ts +++ b/apps/builder/__tests__/update-webchat-action-permission.test.ts @@ -3,7 +3,7 @@ import { beforeEach, expect, test, vi } from "vitest" const mockHasWorkspacePermission = vi.fn() -const mockFindByWorkspaceIdAndId = vi.fn() +const mockFindByIdForWorkspace = vi.fn() const mockUpdate = vi.fn() const mockIsCommunity = vi.fn(() => false) const SUPER_ADMIN_ERROR_RE = /super admin/i @@ -32,7 +32,7 @@ vi.mock("@/lib/auth/permission-routes", () => ({ vi.mock("@chatbotx.io/business", () => ({ integrationWebchatService: { - findByWorkspaceIdAndId: mockFindByWorkspaceIdAndId, + findByIdForWorkspace: mockFindByIdForWorkspace, update: mockUpdate, }, })) @@ -62,6 +62,7 @@ const makeInput = (permissions: Record) => ({ beforeEach(() => { vi.clearAllMocks() + mockFindByIdForWorkspace.mockResolvedValue({ id: "webchat-1" }) mockUpdate.mockResolvedValue(undefined) }) @@ -77,6 +78,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(mockFindByIdForWorkspace).not.toHaveBeenCalled() expect(mockUpdate).not.toHaveBeenCalled() }) @@ -103,8 +105,9 @@ test("proceeds to update when the caller is a superAdmin", async () => { makeInput({ superAdmin: true }), ) - expect(mockUpdate).toHaveBeenCalledWith( - { workspaceId: "workspace-1", id: "webchat-1" }, - expect.objectContaining({ name: "Support" }), - ) + expect(mockFindByIdForWorkspace).toHaveBeenCalledWith({ + id: "webchat-1", + workspaceId: "workspace-1", + }) + expect(mockUpdate).toHaveBeenCalled() }) diff --git a/apps/builder/__tests__/webchat-page-guards.test.tsx b/apps/builder/__tests__/webchat-page-guards.test.tsx index 06398ff009..74e6911e4c 100644 --- a/apps/builder/__tests__/webchat-page-guards.test.tsx +++ b/apps/builder/__tests__/webchat-page-guards.test.tsx @@ -38,17 +38,10 @@ vi.mock("@chatbotx.io/business", () => ({ | null | undefined, ) => Boolean(workspace?.scheduledDeletionAt), + integrationWebchatService: { findByIdForWorkspaceOrNull: mockFindFirst }, workspaceService: { find: mockWorkspaceFind }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - integrationWebchatModel: { findFirst: mockFindFirst }, - }, - }, -})) - vi.mock("@/lib/domain", () => ({ getDomainFromHeader: mockGetDomainFromHeader, })) diff --git a/apps/builder/src/app/(no-sidebar)/webchat/page.tsx b/apps/builder/src/app/(no-sidebar)/webchat/page.tsx index 1e71bb6586..6adfa55e22 100644 --- a/apps/builder/src/app/(no-sidebar)/webchat/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/webchat/page.tsx @@ -1,9 +1,9 @@ import { + integrationWebchatService, isWorkspaceScheduledForDeletion, workspaceService, } from "@chatbotx.io/business" import { ensureBrandingMenuEntry } from "@chatbotx.io/business/branding" -import { db } from "@chatbotx.io/database/client" import { zodBigintAsString } from "@chatbotx.io/utils" import type { SearchParams } from "next/dist/server/request/search-params" import { headers } from "next/headers" @@ -65,12 +65,11 @@ export default async function WebchatPage(props: WebchatPageProps) { return notFound() } - const targetWebchat = await db.query.integrationWebchatModel.findFirst({ - where: { + const targetWebchat = + await integrationWebchatService.findByIdForWorkspaceOrNull({ id: data.webchatId, workspaceId: data.workspaceId, - }, - }) + }) if (!targetWebchat) { return notFound() diff --git a/apps/builder/src/features/broadcasts/api/private.ts b/apps/builder/src/features/broadcasts/api/private.ts index 8a8cd62207..d646ec6677 100644 --- a/apps/builder/src/features/broadcasts/api/private.ts +++ b/apps/builder/src/features/broadcasts/api/private.ts @@ -4,11 +4,9 @@ import { listBroadcastContactsRequest, listBroadcastContactsResponse, } from "@chatbotx.io/analytics/schemas" -import { broadcastService, contactInboxService } from "@chatbotx.io/business" -import { notFoundException } from "@chatbotx.io/business/errors" +import { broadcastService } from "@chatbotx.io/business" import { channelTypes } from "@chatbotx.io/database/partials" import { z } from "zod" -import { mapStatsContactRow } from "@/features/common/lib/map-stats-contact-row" import { workspaceAuthorizedMidddleware } from "@/middlewares/auth" import { authorizedAPI } from "@/orpc" @@ -137,56 +135,18 @@ export const broadcastPrivateAPIs = { .handler(async ({ input }) => { const { workspaceId, broadcastId, eventType, page, perPage } = input - const [existingId] = await broadcastService.listExistingIds({ - workspaceId, - ids: [broadcastId], - }) - if (!existingId) { - throw notFoundException("Broadcast not found") - } - if (!eventType) { return { data: [], total: 0, page, pageCount: 0 } } - const { contactInboxIds, contactEventMap, total } = - await broadcastAnalyticsService.getContacts({ + const { data, total, pageCount } = + await broadcastService.listContactsPage({ workspaceId, broadcastId, eventType, page, perPage, }) - const pageCount = Math.ceil(total / perPage) - - if (contactInboxIds.length === 0) { - return { data: [], total, page, pageCount } - } - - const contactInboxes = await contactInboxService.findManyByIds({ - workspaceId, - ids: contactInboxIds, - }) - - const contactMap = new Map(contactInboxes.map((c) => [c.id, c])) - - const data = contactInboxIds - .map((contactInboxId) => { - const row = mapStatsContactRow( - contactInboxId, - contactEventMap.get(contactInboxId), - contactMap.get(contactInboxId), - ) - if (!row) { - return null - } - return { - ...row, - conversationId: - contactMap.get(contactInboxId)?.conversation?.id ?? "", - } - }) - .filter((c) => c !== null) return { data, total, page, pageCount } }), diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index 410361f1e4..74fc6ec1d7 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -1,10 +1,8 @@ -import { broadcastAnalyticsService } from "@chatbotx.io/analytics" -import { broadcastService, contactInboxService } from "@chatbotx.io/business" +import { broadcastService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { broadcastStatuses } from "@chatbotx.io/database/partials" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" -import { mapStatsContactRow } from "@/features/common/lib/map-stats-contact-row" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -14,7 +12,6 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { listBroadcastAudience, listBroadcasts } from "../queries" import { createBroadcastRequest, resolveScheduleTime, @@ -64,7 +61,7 @@ export const broadcastsPublicRouter = { .errors(possibleErrorsOnListingResource) .handler( async ({ context, input }) => - await listBroadcasts({ + await broadcastService.list({ workspaceId: context.workspace.id, ...input, sort: [{ id: "createdAt", desc: true }], @@ -108,7 +105,7 @@ export const broadcastsPublicRouter = { .errors(possibleErrorsOnFindingResource) .handler( async ({ context, input }) => - await listBroadcastAudience({ + await broadcastService.listAudience({ idOrName: input.idOrName, workspaceId: context.workspace.id, page: input.page, @@ -132,44 +129,16 @@ export const broadcastsPublicRouter = { .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { const { id, eventType, page, perPage } = input - const [existingId] = await broadcastService.listExistingIds({ + const { data, pageCount } = await broadcastService.listContactsPage({ workspaceId: context.workspace.id, - ids: [id], - }) - if (!existingId) { - throw notFoundException("Broadcast not found") - } - - const { contactInboxIds, contactEventMap, total } = - await broadcastAnalyticsService.getContacts({ - workspaceId: context.workspace.id, - broadcastId: id, - eventType, - page, - perPage, - }) - const pageCount = Math.ceil(total / perPage) - - if (contactInboxIds.length === 0) { - return { data: [], pageCount } - } - - const contactInboxes = await contactInboxService.findManyByIds({ - workspaceId: context.workspace.id, - ids: contactInboxIds, + broadcastId: id, + eventType, + page, + perPage, }) - const contactMap = new Map(contactInboxes.map((c) => [c.id, c])) - - const data = contactInboxIds - .map((contactInboxId) => - mapStatsContactRow( - contactInboxId, - contactEventMap.get(contactInboxId), - contactMap.get(contactInboxId), - ), - ) - .filter((row) => row !== null) + // `conversationId` is a superset the public response schema doesn't + // declare — zod strips it silently, so returning it here is harmless. return { data, pageCount } }), diff --git a/apps/builder/src/features/broadcasts/queries/index.ts b/apps/builder/src/features/broadcasts/queries/index.ts index 36698bf9d2..f535ee920b 100644 --- a/apps/builder/src/features/broadcasts/queries/index.ts +++ b/apps/builder/src/features/broadcasts/queries/index.ts @@ -8,12 +8,3 @@ export async function listBroadcasts( ): Promise> { return await broadcastService.list(input) } - -export async function listBroadcastAudience(input: { - idOrName: string - workspaceId: string - page?: number | null - perPage?: number | null -}) { - return await broadcastService.listAudience(input) -} diff --git a/apps/builder/src/features/integration-smtp/actions/create-smtp.action.ts b/apps/builder/src/features/integration-smtp/actions/create-smtp.action.ts index 3fe5873f7b..4dab8ef4ac 100644 --- a/apps/builder/src/features/integration-smtp/actions/create-smtp.action.ts +++ b/apps/builder/src/features/integration-smtp/actions/create-smtp.action.ts @@ -1,9 +1,13 @@ "use server" +import { integrationSmtpService, workspaceService } from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" +import { ChatbotXException } from "@chatbotx.io/business/errors" import { workspaceIdrequestParams } from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" +import { resolveSmtpHostAndPort } from "../lib/smtp-host" +import { verifySmtpConnection } from "../lib/verify-connection" import { createSmtpRequest } from "../schema/mutation" -import { createSmtp } from "../services/smtp.service" export const createSmtpAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) @@ -13,7 +17,44 @@ export const createSmtpAction = workspaceActionClient bindArgsParsedInputs: [workspaceId], parsedInput, } = props - const inbox = await createSmtp(workspaceId, parsedInput) + const { fromAddress, username, password, provider, ...rest } = parsedInput + + await verifySmtpConnection(parsedInput) + + const { host, port } = resolveSmtpHostAndPort(provider, { + host: rest.host, + port: rest.port, + }) + + const workspace = await workspaceService.find({ + where: { id: workspaceId }, + }) + if (!workspace) { + throw new ChatbotXException("Workspace not found") + } + + const { inbox, wasCreated } = await integrationSmtpService.connect({ + workspaceId, + ownerId: workspace.ownerId, + name: username, + fromAddress, + auth: { + authType: "custom", + provider, + host, + port, + username, + password, + }, + }) + + if (wasCreated) { + await auditService.record({ + workspaceId, + action: "connect", + detail: `connected a new SMTP channel (#${inbox.id})`, + }) + } return { id: inbox.id, diff --git a/apps/builder/src/features/integration-smtp/actions/delete-smtp.action.ts b/apps/builder/src/features/integration-smtp/actions/delete-smtp.action.ts index b1401cf6d1..cd4a2c4457 100644 --- a/apps/builder/src/features/integration-smtp/actions/delete-smtp.action.ts +++ b/apps/builder/src/features/integration-smtp/actions/delete-smtp.action.ts @@ -1,8 +1,9 @@ "use server" +import { integrationSmtpService, workspaceService } from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { deleteSmtp } from "../services/smtp.service" export const deleteSmtpAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -10,5 +11,22 @@ export const deleteSmtpAction = workspaceActionClient const { bindArgsParsedInputs: [workspaceId, id], } = props - await deleteSmtp(workspaceId, id) + + const [integration, workspace] = await Promise.all([ + integrationSmtpService.findByIdForWorkspace({ id, workspaceId }), + workspaceService.findById({ id: workspaceId }), + ]) + + await integrationSmtpService.disconnect({ + workspaceId, + id: integration.id, + inboxId: integration.inboxId, + ownerId: workspace.ownerId, + }) + + await auditService.record({ + workspaceId, + action: "disconnect", + detail: `disconnected the SMTP channel (#${integration.id})`, + }) }) diff --git a/apps/builder/src/features/integration-smtp/actions/update-smtp.action.ts b/apps/builder/src/features/integration-smtp/actions/update-smtp.action.ts index 11f0c24900..fd46f0d103 100644 --- a/apps/builder/src/features/integration-smtp/actions/update-smtp.action.ts +++ b/apps/builder/src/features/integration-smtp/actions/update-smtp.action.ts @@ -1,9 +1,13 @@ "use server" +import { integrationSmtpService } from "@chatbotx.io/business" +import { auditService, isSameJsonValue } from "@chatbotx.io/business/audit" +import type { SmtpAuthValue } from "@chatbotx.io/integration-smtp" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" +import { resolveSmtpHostAndPort } from "../lib/smtp-host" +import { verifySmtpConnection } from "../lib/verify-connection" import { updateSmtpRequest } from "../schema/mutation" -import { updateSmtp } from "../services/smtp.service" export const updateSmtpAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -14,5 +18,56 @@ export const updateSmtpAction = workspaceActionClient parsedInput, } = props - return await updateSmtp(workspaceId, id, parsedInput) + await verifySmtpConnection(parsedInput) + + const integration = await integrationSmtpService.findByIdForWorkspace({ + id, + workspaceId, + }) + + const currentAuth = integration.auth as SmtpAuthValue + const provider = parsedInput.provider ?? currentAuth.provider + + const { host, port } = resolveSmtpHostAndPort(provider, { + host: parsedInput.host || currentAuth.host, + port: parsedInput.port || currentAuth.port, + }) + + const updatedAuth: SmtpAuthValue = { + authType: "custom", + provider, + host, + port, + username: parsedInput.username ?? currentAuth.username, + password: parsedInput.password ?? currentAuth.password, + } + + const name = parsedInput.username ?? integration.name + + const updated = await integrationSmtpService.update({ + workspaceId, + id: integration.id, + auth: updatedAuth, + name, + fromAddress: parsedInput.fromAddress, + }) + + const hasChanged = !isSameJsonValue( + { auth: updatedAuth, name, fromAddress: parsedInput.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 }) diff --git a/apps/builder/src/features/integration-smtp/lib/smtp-host.ts b/apps/builder/src/features/integration-smtp/lib/smtp-host.ts new file mode 100644 index 0000000000..4fc1bbc8af --- /dev/null +++ b/apps/builder/src/features/integration-smtp/lib/smtp-host.ts @@ -0,0 +1,17 @@ +import type { SmtpProvider } from "@chatbotx.io/integration-smtp" +import { smtpHostMap } from "@chatbotx.io/integration-smtp" + +/** + * Resolves the effective host/port for a provider. Anything but "other" is + * pinned to the provider's known host/port (ignoring any host/port the + * caller supplied); "other" passes the caller-supplied values through. + */ +export function resolveSmtpHostAndPort( + provider: SmtpProvider, + fallback: { host: string; port: number }, +): { host: string; port: number } { + if (provider === "other") { + return fallback + } + return smtpHostMap[provider] +} diff --git a/apps/builder/src/features/integration-smtp/lib/verify-connection.ts b/apps/builder/src/features/integration-smtp/lib/verify-connection.ts new file mode 100644 index 0000000000..8c89f4dd4f --- /dev/null +++ b/apps/builder/src/features/integration-smtp/lib/verify-connection.ts @@ -0,0 +1,29 @@ +import { ChatbotXException } from "@chatbotx.io/business/errors" +import { smtpHostMap } from "@chatbotx.io/integration-smtp" +import { createSmtpTransporter } from "@chatbotx.io/mail/transport" +import { getTranslations } from "next-intl/server" +import type { CreateSmtpRequest } from "../schema/mutation" + +export async function verifySmtpConnection(input: CreateSmtpRequest) { + const t = await getTranslations() + + const { host, port } = + input.provider === "other" + ? { host: input.host, port: input.port } + : smtpHostMap[input.provider] + + const transporter = createSmtpTransporter({ + host, + port, + username: input.username, + password: input.password, + }) + + try { + await transporter.verify() + } catch { + throw new ChatbotXException(t("smtp.errors.connectionFailed")) + } finally { + transporter.close() + } +} diff --git a/apps/builder/src/features/integration-smtp/queries/index.ts b/apps/builder/src/features/integration-smtp/queries/index.ts index 1632258561..0a287ec7ed 100644 --- a/apps/builder/src/features/integration-smtp/queries/index.ts +++ b/apps/builder/src/features/integration-smtp/queries/index.ts @@ -1,29 +1,15 @@ "use server" 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" import type { IntegrationSmtpResource } from "../schema/resource" -export const findIntegrationSmtp = async ( - input: Partial>, -): Promise => - findOrFail({ table: integrationSmtpModel, where: input }) - export const listIntegrationSmtps = async (input: { workspaceId: string }): Promise<{ data: IntegrationSmtpResource[] }> => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const data = await integrationSmtpService.listByWorkspaceId(input.workspaceId) + const data = await integrationSmtpService.listByWorkspace(input.workspaceId) - return { - data: data.map(({ id, name, fromAddress }) => ({ - id, - name, - fromAddress, - })), - } + return { data } } diff --git a/apps/builder/src/features/integration-smtp/services/smtp.service.ts b/apps/builder/src/features/integration-smtp/services/smtp.service.ts deleted file mode 100644 index d734c55507..0000000000 --- a/apps/builder/src/features/integration-smtp/services/smtp.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { integrationSmtpService } from "@chatbotx.io/business" -import { ChatbotXException } from "@chatbotx.io/business/errors" -import { smtpHostMap } from "@chatbotx.io/integration-smtp" -import { createSmtpTransporter } from "@chatbotx.io/mail/transport" -import { getTranslations } from "next-intl/server" -import type { CreateSmtpRequest, UpdateSmtpRequest } from "../schema/mutation" - -export async function verifySmtpConnection(input: CreateSmtpRequest) { - const t = await getTranslations() - - const { host, port } = - input.provider === "other" - ? { host: input.host, port: input.port } - : smtpHostMap[input.provider] - - const transporter = createSmtpTransporter({ - host, - port, - username: input.username, - password: input.password, - }) - - try { - await transporter.verify() - } catch { - throw new ChatbotXException(t("smtp.errors.connectionFailed")) - } finally { - transporter.close() - } -} - -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, -) { - await verifySmtpConnection(input) - const { host, port } = resolveHostAndPort(input) - return await integrationSmtpService.create(workspaceId, { - ...input, - host, - port, - }) -} - -export async function updateSmtp( - workspaceId: string, - id: string, - input: UpdateSmtpRequest, -) { - await verifySmtpConnection(input) - const { host, port } = resolveHostAndPort(input) - return await integrationSmtpService.update(workspaceId, id, { - ...input, - host, - port, - }) -} - -export async function deleteSmtp(workspaceId: string, id: string) { - 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 ae2578e160..ee292fcc5a 100644 --- a/apps/builder/src/features/integration-telegram/actions/connect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/connect.action.ts @@ -8,7 +8,6 @@ import { } 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 type { UserModel } from "@chatbotx.io/database/types" import { redirect } from "next/navigation" import { isCloud } from "@/env" @@ -32,7 +31,7 @@ export const connectTelegramAction = authActionClient ctx: { user: UserModel } }) => { try { - let workspaceId = parsedInput.workspaceId + const workspaceId = parsedInput.workspaceId ?? undefined // Validate bot token and fetch bot info from Telegram const botData = await integrations.telegram.runAction("connect", { @@ -66,54 +65,29 @@ export const connectTelegramAction = authActionClient } } - const result = await db.transaction(async (tx) => { - let createdWorkspace = false - - if (!workspaceId) { - const workspace = await workspaceService.create({ - tx, - createdBy: ctx.user.id, - data: { - name: botData.username, - timezone: "UTC", - ownerId: ctx.user.id, - }, - }) - workspaceId = workspace.id - createdWorkspace = true - } - - const { integrationId, wasCreated } = - await telegramIntegrationService.connect({ - tx, - ownerId, - workspaceId: workspaceId as string, - botId: botData.id, - botUsername: botData.username, + const result = await telegramIntegrationService.connect({ + workspaceId, + ownerId, + createdBy: ctx.user.id, + botId: botData.id, + botUsername: botData.username, + botToken: parsedInput.botToken, + onConnected: async () => { + // Register webhook URL with Telegram + const webhookUrl = buildBrokerCallbackUrl( + `/integrations/telegram/webhook?botId=${botData.id}`, + ) + await integrations.telegram.runAction("registerWebhook", { botToken: parsedInput.botToken, + webhookUrl, }) - - // Register webhook URL with Telegram - const webhookUrl = buildBrokerCallbackUrl( - `/integrations/telegram/webhook?botId=${botData.id}`, - ) - await integrations.telegram.runAction("registerWebhook", { - botToken: parsedInput.botToken, - webhookUrl, - }) - - return { - workspaceId, - createdWorkspace, - wasCreated, - integrationId, - } + }, }) if (result.createdWorkspace) { await auditService.record({ userId: ctx.user.id, - workspaceId: result.workspaceId as string, + workspaceId: result.workspaceId, action: "create", detail: `created the workspace (#${result.workspaceId})`, }) @@ -121,7 +95,7 @@ export const connectTelegramAction = authActionClient if (result.wasCreated) { await auditService.record({ - workspaceId: result.workspaceId as string, + workspaceId: result.workspaceId, action: "connect", detail: `connected a new Telegram channel (#${result.integrationId})`, }) @@ -137,9 +111,6 @@ export const connectTelegramAction = authActionClient } throw error } - if (isDatabaseError(error) && error.cause.code === "23505") { - throw new ChatbotXException("Bot already connected") - } logger.error(error, "Failed to connect Telegram bot") throw new ChatbotXException( 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 05715d064c..74534d30b2 100644 --- a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts @@ -1,12 +1,10 @@ "use server" import { - inboxService, telegramIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db } from "@chatbotx.io/database/client" import type { TelegramAuthValue } from "@chatbotx.io/integration-telegram" import { type WorkspaceIdAndIdRequestParams, @@ -25,7 +23,7 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationTelegram, workspace] = await Promise.all([ - telegramIntegrationService.findByWorkspaceIdAndId({ workspaceId, id }), + telegramIntegrationService.findByIdForWorkspace({ id, workspaceId }), workspaceService.findById({ id: workspaceId }), ]) @@ -40,18 +38,11 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired ) } - await db.transaction(async (tx) => { - await telegramIntegrationService.disconnect({ - id: integrationTelegram.id, - tx, - }) - await inboxService.disconnect({ - inboxId: integrationTelegram.inboxId, - ownerId: workspace.ownerId, - workspaceId, - reason: "manual", - tx, - }) + await telegramIntegrationService.disconnect({ + workspaceId, + id: integrationTelegram.id, + inboxId: integrationTelegram.inboxId, + ownerId: workspace.ownerId, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-telegram/queries/index.ts b/apps/builder/src/features/integration-telegram/queries/index.ts index d6e9871dd5..f2ddfb762f 100644 --- a/apps/builder/src/features/integration-telegram/queries/index.ts +++ b/apps/builder/src/features/integration-telegram/queries/index.ts @@ -1,33 +1,20 @@ import { telegramIntegrationService } from "@chatbotx.io/business" import type { IntegrationTelegramModel } from "@chatbotx.io/database/types" -import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" export const listIntegrationTelegrams = async ({ where, }: { where: Partial> }): Promise<{ data: IntegrationTelegramModel[] }> => { - const data = await telegramIntegrationService.listByWorkspaceId(where) + const data = await telegramIntegrationService.listByWorkspace(where) return { data } } -export const findIntegrationTelegram = async ({ - workspaceId, -}: { - workspaceId: string -}): Promise => { - await assertCurrentUserCanAccessChatbot(workspaceId) - - return ( - (await telegramIntegrationService.findByWorkspaceId(workspaceId)) ?? null - ) -} - /** Internal lookup by botId — no auth check, for use in webhook handler only */ export const findIntegrationTelegramByBotId = async ({ botId, }: { botId: string }): Promise => - (await telegramIntegrationService.findByBotId(botId)) ?? null + await telegramIntegrationService.findByBotId(botId) 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 fedb3d3634..1e6910ea80 100644 --- a/apps/builder/src/features/integration-tiktok/actions/connect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/connect.action.ts @@ -4,7 +4,6 @@ import { } 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 type { TiktokAuthValue } from "@chatbotx.io/integration-tiktok" import { redirect } from "next/navigation" @@ -34,21 +33,19 @@ export async function connectTiktokHandler({ const openId = authValue.metadata.openId const displayName = authValue.metadata.displayName + const username = authValue.metadata.username const { ownerId } = await workspaceService.findById({ id: workspaceId }) try { - const { wasCreated, integration } = await db.transaction(async (tx) => - tiktokIntegrationService.connect({ - tx, - ownerId, - workspaceId, - openId, - username: authValue.metadata.username, - displayName, - auth: authValue, - }), - ) + const { wasCreated, integration } = await tiktokIntegrationService.connect({ + workspaceId, + ownerId, + openId, + username, + displayName, + auth: authValue, + }) if (!integration) { return 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 74e4316b52..3c5085d4a6 100644 --- a/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts @@ -1,12 +1,10 @@ "use server" import { - inboxService, tiktokIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db } from "@chatbotx.io/database/client" import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, @@ -26,18 +24,11 @@ export const disconnectTiktokAction = workspaceActionClientAllowExpired workspaceService.findById({ id: workspaceId }), ]) - await db.transaction(async (tx) => { - await tiktokIntegrationService.disconnect({ - id: integrationTiktok.id, - tx, - }) - await inboxService.disconnect({ - inboxId: integrationTiktok.inboxId, - ownerId: workspace.ownerId, - workspaceId, - reason: "manual", - tx, - }) + await tiktokIntegrationService.disconnect({ + workspaceId, + id: integrationTiktok.id, + inboxId: integrationTiktok.inboxId, + ownerId: workspace.ownerId, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-tiktok/queries/index.ts b/apps/builder/src/features/integration-tiktok/queries/index.ts index f64e376345..376dbaa521 100644 --- a/apps/builder/src/features/integration-tiktok/queries/index.ts +++ b/apps/builder/src/features/integration-tiktok/queries/index.ts @@ -1,29 +1,19 @@ import { tiktokIntegrationService } from "@chatbotx.io/business" import type { IntegrationTiktokModel } from "@chatbotx.io/database/types" -import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" export const listIntegrationTiktoks = async ({ where, }: { where: Partial> }): Promise<{ data: IntegrationTiktokModel[] }> => { - const data = await tiktokIntegrationService.listByWorkspaceId(where) + const data = await tiktokIntegrationService.listByWorkspace(where) return { data } } -export const findIntegrationTiktok = async ({ - workspaceId, -}: { - workspaceId: string -}): Promise => { - await assertCurrentUserCanAccessChatbot(workspaceId) - - return (await tiktokIntegrationService.findByWorkspaceId(workspaceId)) ?? null -} - +/** Internal lookup by openId — no auth check, for use in webhook handler only */ export const findIntegrationTiktokByOpenId = async ({ openId, }: { openId: string }): Promise => - (await tiktokIntegrationService.findByOpenId(openId)) ?? null + await tiktokIntegrationService.findByOpenId(openId) 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 22bed0339d..b19a6f4f27 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 @@ -3,12 +3,10 @@ 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 { isCommunity } from "@/env" import { getTenantSettings } from "@/features/tenant/utils" import { authActionClient } from "@/lib/safe-action" @@ -20,6 +18,16 @@ export const createWebchatAction = authActionClient .action(async ({ parsedInput, ctx }) => { const { authorizedDomains, ...rest } = parsedInput + if ( + parsedInput.workspaceId && + !(await hasWorkspaceAccess({ + workspaceId: parsedInput.workspaceId, + user: ctx.user, + })) + ) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } + // Community keeps the "Built with" branding entry; silently re-add it // (same precedent as moveBrandingMenuLast in the messenger action). const persistentMenus = isCommunity() @@ -29,63 +37,30 @@ export const createWebchatAction = authActionClient }) : rest.persistentMenus - let workspaceId = parsedInput.workspaceId - let ownerId = ctx.user.id - - const result = await db.transaction(async (tx) => { - 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 }, - }) - ownerId = workspace.ownerId - } else { - const newChatbot = await workspaceService.create({ - tx, - createdBy: ownerId, - data: { - name: parsedInput.name, - timezone: "UTC", - ownerId, - }, - }) - workspaceId = newChatbot.id - createdWorkspace = true - } - - const created = await integrationWebchatService.create( - { - workspaceId, - ownerId, - data: { - ...rest, - persistentMenus, - authorizedDomains: authorizedDomains.map((domain) => domain.value), - auth: {}, - customCss: rest.customCss ?? null, - }, - }, - tx, - ) - - return { workspaceId, createdWorkspace, webchatId: created.id } + const result = await integrationWebchatService.createWithWorkspace({ + workspaceId: parsedInput.workspaceId ?? undefined, + createdBy: ctx.user.id, + workspaceName: parsedInput.name, + data: { + ...rest, + persistentMenus, + authorizedDomains: authorizedDomains.map((domain) => domain.value), + auth: {}, + customCss: rest.customCss ?? null, + }, }) if (result.createdWorkspace) { await auditService.record({ userId: ctx.user.id, - workspaceId: result.workspaceId as string, + workspaceId: result.workspaceId, action: "create", detail: `created the workspace (#${result.workspaceId})`, }) } await auditService.record({ - workspaceId: result.workspaceId as string, + workspaceId: result.workspaceId, action: "connect", detail: `connected a new Webchat channel (#${result.webchatId})`, }) 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 bcf2b4feae..d899f58bb2 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 @@ -31,6 +31,11 @@ export const updateWebchatAction = workspaceActionClient throw new Error("You need to be a super admin to update this webchat") } + const integration = await integrationWebchatService.findByIdForWorkspace({ + id, + workspaceId, + }) + // Community keeps the "Built with" branding entry; silently re-add it // (same precedent as moveBrandingMenuLast in the messenger action). const persistentMenus = @@ -41,9 +46,10 @@ export const updateWebchatAction = workspaceActionClient }) : rest.persistentMenus - await integrationWebchatService.update( - { workspaceId, id }, - { + await integrationWebchatService.update({ + workspaceId, + id: integration.id, + data: { ...rest, persistentMenus, welcomeFlowId: welcomeFlowId?.length ? welcomeFlowId : null, @@ -51,5 +57,5 @@ export const updateWebchatAction = workspaceActionClient ? 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 784e012a91..944b5803e0 100644 --- a/apps/builder/src/features/integration-webchat/queries/index.ts +++ b/apps/builder/src/features/integration-webchat/queries/index.ts @@ -2,7 +2,6 @@ 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" import type { ListIntegrationWebchatsRequest } from "../schema/query" @@ -11,20 +10,18 @@ export const listIntegrationWebchats = async ( ) => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const pagination = parsePagination(input) - const [data, totalRows] = await integrationWebchatService.listByWorkspaceId({ + return await integrationWebchatService.list({ workspaceId: input.workspaceId, - pagination, + page: input.page, + perPage: input.perPage, }) - - const pageCount = pagination?.limit - ? Math.ceil(totalRows / pagination.limit) - : 1 - return { data, pageCount } } export async function findIntegrationWebchat( where: Pick, ) { - return await integrationWebchatService.findByWorkspaceIdAndId(where) + return await integrationWebchatService.findByIdForWorkspace({ + id: where.id, + workspaceId: where.workspaceId, + }) } 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 b2cfd1be95..f28e1be166 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,17 +1,8 @@ -import { - tagSyncService, - workspaceService, - zaloIntegrationService, -} from "@chatbotx.io/business" +import { workspaceService, zaloIntegrationService } 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 { - channelTypes, - type ZaloCredential, -} from "@chatbotx.io/database/partials" +import type { ZaloCredential } from "@chatbotx.io/database/partials" import type { ZaloAuthValue } from "@chatbotx.io/integration-zalo" -import { invalidateCacheByTags } from "@chatbotx.io/redis" import { redirect } from "next/navigation" import { integrations } from "@/integration" import { getGuestClientIp } from "@/lib/rate-limit/guest-rate-limit" @@ -43,23 +34,14 @@ export async function connectZaloHandler({ const { ownerId } = await workspaceService.findById({ id: workspaceId }) - let connectedIntegrationId: string | undefined - let channelWasCreated = false - let wasDuplicate = false + let result: { integrationId: string | undefined; wasCreated: boolean } try { - await db.transaction(async (tx) => { - const { integrationId, wasCreated } = - await zaloIntegrationService.connect({ - tx, - ownerId, - workspaceId, - oaId: authValue.oaId, - oaName: authValue.metadata.oaName, - auth: authValue, - }) - connectedIntegrationId = integrationId - channelWasCreated = wasCreated - wasDuplicate = !integrationId + result = await zaloIntegrationService.connect({ + workspaceId, + ownerId, + oaId: authValue.oaId, + name: authValue.metadata.oaName, + auth: authValue, }) } catch (error) { if ( @@ -73,31 +55,14 @@ export async function connectZaloHandler({ throw error } - if (wasDuplicate) { - redirect( - `/space/${workspaceId}/settings/channels?channel=zalo&error=duplicated`, - ) - } - - if (channelWasCreated) { + if (result.wasCreated) { await auditService.record({ userId, workspaceId, action: "connect", - detail: `connected a new Zalo channel (#${connectedIntegrationId})`, + detail: `connected a new Zalo channel (#${result.integrationId})`, ipAddress: getGuestClientIp(req.headers), userAgent: req.headers.get("user-agent") ?? undefined, }) } - - await invalidateCacheByTags([`workspaces:${workspaceId}#zalos`]) - - // Import any tags already on the OA into local tags + mappings. - if (connectedIntegrationId) { - await tagSyncService.enqueueChannelScan({ - workspaceId, - channelType: channelTypes.enum.zalo, - integrationId: connectedIntegrationId, - }) - } } 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 6afc571014..ad1672a4b9 100644 --- a/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts @@ -1,12 +1,7 @@ "use server" -import { - inboxService, - workspaceService, - zaloIntegrationService, -} from "@chatbotx.io/business" +import { workspaceService, zaloIntegrationService } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db } from "@chatbotx.io/database/client" import { isRevokedTokenError, type ZaloAuthValue, @@ -40,15 +35,11 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired } } - await db.transaction(async (tx) => { - await zaloIntegrationService.disconnect({ id: integrationZalo.id, tx }) - await inboxService.disconnect({ - inboxId: integrationZalo.inboxId, - ownerId: workspace.ownerId, - workspaceId, - reason: "manual", - tx, - }) + await zaloIntegrationService.disconnect({ + workspaceId, + id: integrationZalo.id, + inboxId: integrationZalo.inboxId, + ownerId: workspace.ownerId, }) await auditService.record({ diff --git a/apps/builder/src/features/integration-zalo/queries/index.ts b/apps/builder/src/features/integration-zalo/queries/index.ts index 8d3271fd5c..c565063a43 100644 --- a/apps/builder/src/features/integration-zalo/queries/index.ts +++ b/apps/builder/src/features/integration-zalo/queries/index.ts @@ -20,7 +20,7 @@ export const listIntegrationZalo = async ({ }: { where: Partial> }): Promise<{ data: IntegrationZaloModel[] }> => { - const data = await zaloIntegrationService.listByWorkspaceId(where) + const data = await zaloIntegrationService.listByWorkspace(where) 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 3a8d87038f..93ab44cf14 100644 --- a/apps/builder/src/features/integrations/queries/get-ai-integrations.ts +++ b/apps/builder/src/features/integrations/queries/get-ai-integrations.ts @@ -1,21 +1,8 @@ import { aiProviders } from "@chatbotx.io/ai" import { integrationService } from "@chatbotx.io/business" -type ListAIIntegrationsProps = { - where: { - workspaceId: string - } -} - -export async function listAIIntegrations(props: ListAIIntegrationsProps) { - return await integrationService.listByWorkspaceIdAndTypes({ - workspaceId: props.where.workspaceId, - integrationTypes: [...aiProviders.options], - }) -} - export async function hasAIIntegration(workspaceId: string): Promise { - return await integrationService.existsByWorkspaceIdAndTypes({ + return await integrationService.hasIntegrationOfTypes({ workspaceId, integrationTypes: [...aiProviders.options], }) diff --git a/apps/builder/src/features/sequences/api/authorized.ts b/apps/builder/src/features/sequences/api/authorized.ts index 1724c43301..951bb5e046 100644 --- a/apps/builder/src/features/sequences/api/authorized.ts +++ b/apps/builder/src/features/sequences/api/authorized.ts @@ -1,6 +1,6 @@ +import { sequenceService } from "@chatbotx.io/business/sequence" import { workspaceAuthorizedMidddleware } from "@/middlewares/auth" import { authorizedAPI } from "@/orpc" -import { listSequences } from "../queries" import { listSequencesRequest, listSequencesResponse } from "../schema/action" export const sequencesWorkspaceAuthAPI = { @@ -14,5 +14,5 @@ export const sequencesWorkspaceAuthAPI = { .input(listSequencesRequest) .use(workspaceAuthorizedMidddleware, (input) => input.workspaceId) .output(listSequencesResponse) - .handler(async ({ input }) => await listSequences(input)), + .handler(async ({ input }) => await sequenceService.list(input)), } diff --git a/apps/builder/src/features/sequences/api/private.ts b/apps/builder/src/features/sequences/api/private.ts index 22eb64f41e..854bd27884 100644 --- a/apps/builder/src/features/sequences/api/private.ts +++ b/apps/builder/src/features/sequences/api/private.ts @@ -5,8 +5,7 @@ import { listSequenceStepContactsRequest, listSequenceStepContactsResponse, } from "@chatbotx.io/analytics/schemas" -import { contactInboxService } from "@chatbotx.io/business" -import { mapStatsContactRow } from "@/features/common/lib/map-stats-contact-row" +import { sequenceService } from "@chatbotx.io/business/sequence" import { workspaceAuthorizedMidddleware } from "@/middlewares/auth" import { authorizedAPI } from "@/orpc" @@ -50,57 +49,19 @@ export const sequencesPrivateAPI = { page, perPage, } = input - const totalValue = total || 0 - const { contactInboxIds, contactEventMap } = - await sequenceAnalyticsService.getContacts({ - workspaceId, - sequenceId, - stepId, - eventType, - page, - perPage, - }) - - if (contactInboxIds.length === 0) { - return { - data: [], - total: totalValue, - page, - pageCount: Math.ceil(totalValue / perPage), - } - } - - const contactInboxes = await contactInboxService.findManyByIds({ + const { + data, + total: totalValue, + pageCount, + } = await sequenceService.listStepContactsPage({ workspaceId, - ids: contactInboxIds, - }) - - const contactMap = new Map(contactInboxes.map((c) => [c.id, c])) - const pageCount = Math.ceil(totalValue / perPage) - - // Shared with the broadcasts private/public "stats contacts" routes — - // `contactId` must be the real Contact id (`eventData.contactId`), not - // the ContactInbox id, because both feed the same `StatsContactsDialog` - // → `addContactTagAction`/`bulkTagStatsContactsAction` path, which tags - // by Contact id. - const data = contactInboxIds.flatMap((contactInboxId) => { - const contactInbox = contactMap.get(contactInboxId) - const conversationId = contactInbox?.conversation?.id - if (!conversationId) { - return [] - } - - const row = mapStatsContactRow( - contactInboxId, - contactEventMap.get(contactInboxId), - contactInbox, - ) - if (!row) { - return [] - } - - return [{ ...row, conversationId }] + sequenceId, + stepId, + eventType, + total: total || 0, + page, + perPage, }) return { data, total: totalValue, page, pageCount } diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 200eb8d99d..39deacfca3 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -10,7 +10,6 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { listSequences } from "../queries" import { createSequenceRequest, listSequencesResponse, @@ -34,7 +33,7 @@ export const sequencesPublicRouter = { .errors(possibleErrorsOnListingResource) .handler( async ({ context, input }) => - await listSequences({ + await sequenceService.list({ ...input, workspaceId: context.workspace.id, }), diff --git a/apps/worker/__tests__/sync-user-quota-reconcile.test.ts b/apps/worker/__tests__/sync-user-quota-reconcile.test.ts index 149295b678..7a20acdbcf 100644 --- a/apps/worker/__tests__/sync-user-quota-reconcile.test.ts +++ b/apps/worker/__tests__/sync-user-quota-reconcile.test.ts @@ -36,20 +36,12 @@ function makeSelectChain() { const chain: Record = {} chain.from = vi.fn(() => chain) chain.innerJoin = vi.fn(() => chain) - chain.where = vi.fn((whereArg: { __in?: string[] }) => { - // The existence filter is the only query built with `inArray` (mocked to - // `{ __in }`); everything else is a scalar COUNT consumed from countResults. - if (whereArg && Array.isArray(whereArg.__in)) { - const rows = whereArg.__in - .filter( - (id) => - state.existingUserIds === null || state.existingUserIds.has(id), - ) - .map((id) => ({ id })) - return Promise.resolve(rows) - } - return Promise.resolve([{ count: state.countResults.shift() ?? 0 }]) - }) + // The existence filter moved to `userService.listExistingIds` (see the + // `@chatbotx.io/business` mock below) — every remaining `db.select` here + // is a scalar COUNT consumed from countResults. + chain.where = vi.fn(() => + Promise.resolve([{ count: state.countResults.shift() ?? 0 }]), + ) return chain } @@ -78,7 +70,6 @@ vi.mock("@chatbotx.io/database/client", () => ({ count: vi.fn(() => ({ count: true })), countDistinct: mockCountDistinct, eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), - inArray: vi.fn((_column: unknown, values: string[]) => ({ __in: values })), isForeignKeyViolationError: vi.fn( (error: unknown) => error instanceof Error && error.message.includes("FK violation"), @@ -116,6 +107,15 @@ vi.mock("@chatbotx.io/business", () => ({ findByOwner: vi.fn(async () => undefined), listActiveOwnerIds: vi.fn(async () => [] as string[]), }, + // Existence filter: mirrors the same `state.existingUserIds` restriction + // the inline `db.select` used before this moved into the service. + userService: { + listExistingIds: vi.fn(async (userIds: string[]) => + userIds.filter( + (id) => state.existingUserIds === null || state.existingUserIds.has(id), + ), + ), + }, })) // liveKeyFor/USER_QUOTA_LABEL live in `@chatbotx.io/utils` (shared with @@ -142,7 +142,6 @@ vi.mock("@chatbotx.io/database/schema", () => ({ role: "wm.role", }, workspaceModel: { id: "ws.id", ownerId: "ws.ownerId" }, - userModel: { id: "user.id" }, })) const redisClient = { diff --git a/apps/worker/src/schedule/handlers/sync-user-quota.ts b/apps/worker/src/schedule/handlers/sync-user-quota.ts index 21aae91295..b1e966e394 100644 --- a/apps/worker/src/schedule/handlers/sync-user-quota.ts +++ b/apps/worker/src/schedule/handlers/sync-user-quota.ts @@ -3,21 +3,26 @@ import { parseLiveCount, tenantService, userQuotaService, + userService, WORKSPACE_USAGE_LABEL, workspaceUsageService, } from "@chatbotx.io/business" +// NOTE: this handler is only partially migrated to the service layer — the +// ghost-id existence check now goes through `userService.listExistingIds`, +// but the reconcile/count/upsert queries below still use `db` directly. +// That's an intentional legacy exception (see `.agents/rules/data-access.md` +// § "Existing exceptions"), not an inconsistency to "fix" incidentally — +// migrating the rest is separate scope. import { count, db, eq, - inArray, isForeignKeyViolationError, sql, } from "@chatbotx.io/database/client" import { contactModel, inboxModel, - userModel, userQuotaModel, workspaceMemberModel, workspaceModel, @@ -93,11 +98,7 @@ export const syncUserQuota = async (): Promise => { // ghost id would violate the `UserQuota → User` foreign key on every run, so // filter the batch against the User table (one indexed lookup per 50 ids) // and drop the stale keys instead of walking them again. - const existingRows = await db - .select({ id: userModel.id }) - .from(userModel) - .where(inArray(userModel.id, batch)) - const existingIds = new Set(existingRows.map((row) => row.id)) + const existingIds = new Set(await userService.listExistingIds(batch)) await Promise.all( batch diff --git a/packages/business/__tests__/broadcast-service-clone.test.ts b/packages/business/__tests__/broadcast-service-clone.test.ts index 1bb67c838f..d027c2ed93 100644 --- a/packages/business/__tests__/broadcast-service-clone.test.ts +++ b/packages/business/__tests__/broadcast-service-clone.test.ts @@ -7,6 +7,11 @@ const broadcastInsert = vi.fn() const targetInsert = vi.fn() const pruneFilter = vi.fn() +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { diff --git a/packages/business/__tests__/broadcast-service-create.test.ts b/packages/business/__tests__/broadcast-service-create.test.ts index 9c71589844..857c95e652 100644 --- a/packages/business/__tests__/broadcast-service-create.test.ts +++ b/packages/business/__tests__/broadcast-service-create.test.ts @@ -43,6 +43,11 @@ const dbMock: { transaction: (fn) => fn(dbMock), } +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: dbMock, and: (...args: unknown[]) => ({ __and: args }), diff --git a/packages/business/__tests__/broadcast-service-drafts.test.ts b/packages/business/__tests__/broadcast-service-drafts.test.ts index b43890d384..d098002c6a 100644 --- a/packages/business/__tests__/broadcast-service-drafts.test.ts +++ b/packages/business/__tests__/broadcast-service-drafts.test.ts @@ -11,6 +11,11 @@ const deleteTargetsWhere = vi.fn() const pruneFilter = vi.fn() const mockDispatchAuditRecord = vi.fn().mockResolvedValue(undefined) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { diff --git a/packages/business/__tests__/broadcast-service-lifecycle.test.ts b/packages/business/__tests__/broadcast-service-lifecycle.test.ts index c48f715bf4..b468969355 100644 --- a/packages/business/__tests__/broadcast-service-lifecycle.test.ts +++ b/packages/business/__tests__/broadcast-service-lifecycle.test.ts @@ -4,6 +4,11 @@ const findManyBroadcast = vi.fn() const updateReturning = vi.fn() const dbSelectWhere = vi.fn() +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { diff --git a/packages/business/__tests__/broadcast-service-list-contacts.test.ts b/packages/business/__tests__/broadcast-service-list-contacts.test.ts new file mode 100644 index 0000000000..b5cf3c0b7b --- /dev/null +++ b/packages/business/__tests__/broadcast-service-list-contacts.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + getContacts: vi.fn(), + findManyByIds: vi.fn(), + dispatchAuditRecord: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: (...conditions: unknown[]) => ({ and: conditions }), + db: { + select: (...args: unknown[]) => mocks.select(...args), + }, + eq: (column: unknown, value: unknown) => ({ eq: [column, value] }), + inArray: (column: unknown, values: unknown) => ({ + inArray: [column, values], + }), + isNull: (column: unknown) => ({ isNull: column }), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactInboxContactFilterSQL: vi.fn(), + contactInboxInteractedWithin24hSQL: vi.fn(), + pruneEmailPhoneFilterConditions: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: {}, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: { id: "broadcast.id", workspaceId: "broadcast.workspaceId" }, + broadcastTargetModel: {}, + contactInboxModel: {}, + contactModel: {}, + contactsOnBroadcastsModel: {}, + conversationModel: {}, + integrationMessengerModel: {}, + integrationWhatsappModel: {}, + messengerMessageTemplateModel: {}, + whatsappMessageTemplateModel: {}, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + chunkById: vi.fn(), + escapeLikePattern: (value: string) => value, + getPaginationWithDefaults: vi.fn(), + likeContains: (value: string) => `%${value}%`, +})) + +vi.mock("@chatbotx.io/flow-config", () => ({ + findTemplateStartStep: vi.fn(), + stepTypes: { + enum: { + sendWaTemplateMessage: "sendWaTemplateMessage", + sendMessengerTemplateMessage: "sendMessengerTemplateMessage", + }, + }, +})) + +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: mocks.getContacts }, +})) + +vi.mock("../src/contact-inbox/service", () => ({ + contactInboxService: { findManyByIds: mocks.findManyByIds }, +})) + +vi.mock("../src/inbox/service", () => ({ inboxService: {} })) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mocks.dispatchAuditRecord, +})) + +const { broadcastService } = await import("../src/broadcast/service") + +beforeEach(() => { + vi.clearAllMocks() + mocks.select.mockReturnValue({ from: mocks.from }) + mocks.from.mockReturnValue({ where: mocks.where }) +}) + +describe("broadcastService.listContactsPage", () => { + test("throws not-found when the broadcast doesn't exist in this workspace", async () => { + mocks.where.mockResolvedValueOnce([]) + + await expect( + broadcastService.listContactsPage({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, + }), + ).rejects.toThrow("Broadcast not found") + + expect(mocks.getContacts).not.toHaveBeenCalled() + }) + + test("returns an empty page without a contact-inbox lookup when there are no matching recipients", async () => { + mocks.where.mockResolvedValueOnce([{ id: "b-1" }]) + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: [], + contactEventMap: new Map(), + total: 0, + }) + + const result = await broadcastService.listContactsPage({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, + }) + + expect(result).toEqual({ data: [], total: 0, pageCount: 0 }) + expect(mocks.findManyByIds).not.toHaveBeenCalled() + }) + + test("joins recipient events with contact-inbox details, defaulting a missing conversationId to an empty string", async () => { + mocks.where.mockResolvedValueOnce([{ id: "b-1" }]) + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1", "ci-no-conversation"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + [ + "ci-no-conversation", + { + contactId: "contact-2", + occurredAt: "2026-01-02T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + total: 2, + }) + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + avatar: null, + }, + }, + { + id: "ci-no-conversation", + sourceId: "src-2", + channel: "whatsapp", + conversation: null, + contact: { + id: "contact-2", + firstName: "Bea", + lastName: null, + fullName: "Bea", + avatar: null, + }, + }, + ]) + + const result = await broadcastService.listContactsPage({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, + }) + + // Both rows are kept — a contact inbox with no conversation still + // belongs in the page; `data.length` must not disagree with `total`. + expect(result.data).toHaveLength(2) + expect(result.total).toBe(2) + expect(result.pageCount).toBe(1) + expect(result.data[0]).toMatchObject({ + contactId: "contact-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }) + expect(result.data[1]).toMatchObject({ + contactId: "contact-2", + contactInboxId: "ci-no-conversation", + conversationId: "", + }) + }) + + test("drops a recipient whose contact-inbox no longer resolves, leaving pageCount driven by the DB total", async () => { + mocks.where.mockResolvedValueOnce([{ id: "b-1" }]) + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1", "ci-gone"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + [ + "ci-gone", + { + contactId: "contact-gone", + occurredAt: "2026-01-02T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + total: 2, + }) + // `getContacts` scopes by `Broadcast.workspaceId` while `findManyByIds` + // scopes by `Contact.workspaceId`, so a contact deleted or moved out of + // the workspace after the send is counted in `total` but has no row here. + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: null, + fullName: "Ada", + avatar: null, + }, + }, + ]) + + const result = await broadcastService.listContactsPage({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, + }) + + // Unresolvable rows are dropped rather than emitted as nulls, and + // `pageCount` stays anchored to the DB total — so `data.length` can be + // shorter than the total implies. + expect(result.data).toHaveLength(1) + expect(result.total).toBe(2) + expect(result.pageCount).toBe(1) + expect(result.data[0]).toMatchObject({ + contactId: "contact-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }) + }) + + test("threads workspaceId through the existence check, analytics lookup, and contact-inbox fetch", async () => { + mocks.where.mockResolvedValueOnce([{ id: "b-1" }]) + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + total: 1, + }) + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: null, + fullName: "Ada", + avatar: null, + }, + }, + ]) + + await broadcastService.listContactsPage({ + workspaceId: "ws-1", + broadcastId: "b-1", + eventType: "message:sent", + page: 1, + perPage: 20, + }) + + expect(mocks.getContacts).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws-1" }), + ) + expect(mocks.findManyByIds).toHaveBeenCalledWith({ + workspaceId: "ws-1", + ids: ["ci-1"], + }) + }) +}) diff --git a/packages/business/__tests__/broadcast-service-resend.test.ts b/packages/business/__tests__/broadcast-service-resend.test.ts index 5156599757..88c636272f 100644 --- a/packages/business/__tests__/broadcast-service-resend.test.ts +++ b/packages/business/__tests__/broadcast-service-resend.test.ts @@ -29,6 +29,11 @@ const { } }) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { transaction: mockDbTransaction, diff --git a/packages/business/__tests__/broadcast-service-transitions.test.ts b/packages/business/__tests__/broadcast-service-transitions.test.ts index ecfbdbe963..a89e1a909e 100644 --- a/packages/business/__tests__/broadcast-service-transitions.test.ts +++ b/packages/business/__tests__/broadcast-service-transitions.test.ts @@ -5,6 +5,11 @@ const updateWhere = vi.fn() const findFirstBroadcast = vi.fn() const mockDispatchAuditRecord = vi.fn().mockResolvedValue(undefined) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { diff --git a/packages/business/__tests__/broadcast-service-update.test.ts b/packages/business/__tests__/broadcast-service-update.test.ts index c4c1aeae25..c3b0d0ed45 100644 --- a/packages/business/__tests__/broadcast-service-update.test.ts +++ b/packages/business/__tests__/broadcast-service-update.test.ts @@ -14,6 +14,11 @@ const { mockFindOrFail, mockUpdate, mockUpdateSet, mockDispatchAuditRecord } = } }) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { update: mockUpdate }, and: (...args: unknown[]) => ({ __and: args }), diff --git a/packages/business/__tests__/integration-inbox-lookup.test.ts b/packages/business/__tests__/integration-inbox-lookup.test.ts index 1253e58115..d917063568 100644 --- a/packages/business/__tests__/integration-inbox-lookup.test.ts +++ b/packages/business/__tests__/integration-inbox-lookup.test.ts @@ -13,10 +13,19 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const findOrFailMock = vi.fn() vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions })), + db: { + transaction: vi.fn(), + }, + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), findOrFail: findOrFailMock, - db: {}, - eq: vi.fn(), - and: vi.fn(), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), + isDatabaseError: vi.fn(() => false), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { zalo: "zalo", telegram: "telegram" } }, + integrationTypes: { enum: { telegram: "telegram" } }, })) vi.mock("@chatbotx.io/database/schema", () => ({ @@ -25,18 +34,30 @@ vi.mock("@chatbotx.io/database/schema", () => ({ 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"), })) +// These new imports (added alongside `connect`/`disconnect` on both +// services) pull in real modules transitively — mock them at the boundary +// so this narrow lookup test doesn't have to satisfy their own dependency +// graphs (e.g. `@chatbotx.io/analytics`'s schema requirements). vi.mock("../src/inbox/connect-channel", () => ({ connectChannelIntegration: vi.fn(), })) +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: vi.fn() }, +})) + +vi.mock("../src/tag/sync.service", () => ({ + tagSyncService: { enqueueChannelScan: vi.fn() }, +})) + +vi.mock("../src/workspace", () => ({ + workspaceService: { create: vi.fn() }, +})) + beforeEach(() => { vi.clearAllMocks() }) diff --git a/packages/business/__tests__/integration-smtp-service.test.ts b/packages/business/__tests__/integration-smtp-service.test.ts new file mode 100644 index 0000000000..0fbaa340d5 --- /dev/null +++ b/packages/business/__tests__/integration-smtp-service.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockConnectChannelIntegration, + mockDelete, + mockDisconnect, + mockInsert, + mockInsertValues, + mockTransaction, + mockUpdate, + mockUpdateReturning, + mockUpdateWhere, +} = vi.hoisted(() => { + const mockDeleteWhere = vi.fn(async () => undefined) + const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) + const mockInsertValues = vi.fn(async () => undefined) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + const mockUpdateReturning = vi.fn(async () => [ + { id: "smtp-1", name: "updated", fromAddress: "a@b.com" }, + ]) + const mockUpdateWhere = vi.fn(() => ({ returning: mockUpdateReturning })) + const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) + const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) + + return { + mockConnectChannelIntegration: vi.fn(), + mockDelete, + mockDisconnect: vi.fn(async () => undefined), + mockInsert, + mockInsertValues, + mockTransaction: vi.fn(async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert, update: mockUpdate }), + ), + mockUpdate, + mockUpdateReturning, + mockUpdateWhere, + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions })), + db: { + delete: mockDelete, + transaction: mockTransaction, + update: mockUpdate, + }, + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + findOrFail: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { smtp: "smtp" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationSmtpModel: { id: "id", workspaceId: "workspaceId" }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: () => "smtp-1", +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: mockConnectChannelIntegration, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mockDisconnect }, +})) + +const { integrationSmtpService } = await import( + "../src/integration-smtp/service" +) + +const auth = { + authType: "custom" as const, + provider: "gmail", + host: "smtp.gmail.com", + port: 587, + username: "user", + password: "pass", +} + +describe("integrationSmtpService.connect", () => { + beforeEach(() => { + vi.clearAllMocks() + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ + delete: mockDelete, + insert: mockInsert, + update: mockUpdate, + }), + ) + }) + + test("passes the pre-resolved host/port straight through into auth", async () => { + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: (inboxId: string) => Promise + }) => { + await props.insertIntegration("inbox-1") + return { inbox: { id: "inbox-1" }, wasCreated: true } + }, + ) + + await integrationSmtpService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + name: "user1", + fromAddress: "from@example.com", + auth, + }) + + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + auth, + fromAddress: "from@example.com", + }), + ) + }) +}) + +describe("integrationSmtpService.update", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns the updated row", async () => { + mockUpdateReturning.mockResolvedValue([ + { id: "smtp-1", name: "updated", fromAddress: "a@b.com" }, + ]) + + const result = await integrationSmtpService.update({ + workspaceId: "ws-1", + id: "smtp-1", + auth, + name: "updated", + fromAddress: "a@b.com", + }) + + expect(result).toEqual({ + id: "smtp-1", + name: "updated", + fromAddress: "a@b.com", + }) + }) + + // The action layer pre-checks ownership via `findByIdForWorkspace`, but the + // method takes a `workspaceId` and must scope on it itself — otherwise a + // future caller that trusts the parameter writes across workspaces. + test("scopes the update by workspaceId as well as id", async () => { + mockUpdateReturning.mockResolvedValue([ + { id: "smtp-1", name: "updated", fromAddress: "a@b.com" }, + ]) + + await integrationSmtpService.update({ + workspaceId: "ws-1", + id: "smtp-1", + auth, + name: "updated", + fromAddress: "a@b.com", + }) + + expect(mockUpdateWhere).toHaveBeenCalledWith({ + conditions: [ + { field: "id", value: "smtp-1" }, + { field: "workspaceId", value: "ws-1" }, + ], + }) + }) +}) + +describe("integrationSmtpService.disconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("deletes then calls inboxService.disconnect", async () => { + const callOrder: string[] = [] + const tx = { + delete: vi.fn(() => { + callOrder.push("delete") + return { where: vi.fn(async () => undefined) } + }), + } + mockDisconnect.mockImplementation(() => { + callOrder.push("inbox-disconnect") + return Promise.resolve() + }) + + await integrationSmtpService.disconnect({ + workspaceId: "ws-1", + id: "smtp-1", + inboxId: "inbox-1", + ownerId: "owner-1", + tx: tx as never, + }) + + expect(callOrder).toEqual(["delete", "inbox-disconnect"]) + }) +}) diff --git a/packages/business/__tests__/integration-telegram-service.test.ts b/packages/business/__tests__/integration-telegram-service.test.ts new file mode 100644 index 0000000000..988ef5421a --- /dev/null +++ b/packages/business/__tests__/integration-telegram-service.test.ts @@ -0,0 +1,201 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockConnectChannelIntegration, + mockDelete, + mockDisconnect, + mockInsert, + mockTransaction, + mockWorkspaceCreate, +} = vi.hoisted(() => { + const mockDeleteWhere = vi.fn(async () => undefined) + const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) + const mockInsertValues = vi.fn(async () => undefined) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + + return { + mockConnectChannelIntegration: vi.fn(), + mockDelete, + mockDisconnect: vi.fn(async () => undefined), + mockInsert, + mockTransaction: vi.fn(async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ), + mockWorkspaceCreate: vi.fn(async () => ({ id: "ws-new" })), + } +}) + +class DatabaseErrorStub extends Error { + cause: { code: string } + constructor(code: string) { + super("db error") + this.cause = { code } + } +} + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + delete: mockDelete, + transaction: mockTransaction, + }, + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + findOrFail: vi.fn(), + isDatabaseError: (error: unknown) => error instanceof DatabaseErrorStub, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + integrationTypes: { enum: { telegram: "telegram" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationTelegramModel: { id: "id", botId: "botId" }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: () => "generated-id", +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: mockConnectChannelIntegration, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mockDisconnect }, +})) + +vi.mock("../src/workspace", () => ({ + workspaceService: { create: mockWorkspaceCreate }, +})) + +const { telegramIntegrationService } = await import( + "../src/integration-telegram/service" +) + +describe("telegramIntegrationService.connect", () => { + beforeEach(() => { + vi.clearAllMocks() + mockConnectChannelIntegration.mockResolvedValue({ wasCreated: true }) + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ) + }) + + test("awaits onConnected inside the transaction (assert ordering)", async () => { + const callOrder: string[] = [] + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => { + callOrder.push("transaction-start") + const result = await callback({ + delete: mockDelete, + insert: mockInsert, + }) + callOrder.push("transaction-end") + return result + }, + ) + const onConnected = vi.fn(() => { + callOrder.push("onConnected") + return Promise.resolve() + }) + + await telegramIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + createdBy: "user-1", + botId: "bot-1", + botUsername: "mybot", + botToken: "token-1", + onConnected, + }) + + expect(callOrder).toEqual([ + "transaction-start", + "onConnected", + "transaction-end", + ]) + expect(onConnected).toHaveBeenCalledTimes(1) + }) + + test("creates a workspace only when workspaceId is absent", async () => { + await telegramIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + createdBy: "user-1", + botId: "bot-1", + botUsername: "mybot", + botToken: "token-1", + onConnected: vi.fn(async () => undefined), + }) + expect(mockWorkspaceCreate).not.toHaveBeenCalled() + + vi.clearAllMocks() + mockConnectChannelIntegration.mockResolvedValue({ wasCreated: true }) + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ) + + const result = await telegramIntegrationService.connect({ + ownerId: "owner-1", + createdBy: "user-1", + botId: "bot-1", + botUsername: "mybot", + botToken: "token-1", + onConnected: vi.fn(async () => undefined), + }) + expect(mockWorkspaceCreate).toHaveBeenCalledTimes(1) + expect(result.createdWorkspace).toBe(true) + expect(result.workspaceId).toBe("ws-new") + }) + + test("a 23505 database error surfaces as ChatbotXException('Bot already connected')", async () => { + mockTransaction.mockImplementation(() => { + throw new DatabaseErrorStub("23505") + }) + + await expect( + telegramIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + createdBy: "user-1", + botId: "bot-1", + botUsername: "mybot", + botToken: "token-1", + onConnected: vi.fn(async () => undefined), + }), + ).rejects.toMatchObject({ message: "Bot already connected" }) + }) +}) + +describe("telegramIntegrationService.disconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("deletes then calls inboxService.disconnect", async () => { + const callOrder: string[] = [] + const tx = { + delete: vi.fn(() => { + callOrder.push("delete") + return { where: vi.fn(async () => undefined) } + }), + } + mockDisconnect.mockImplementation(() => { + callOrder.push("inbox-disconnect") + return Promise.resolve() + }) + + await telegramIntegrationService.disconnect({ + workspaceId: "ws-1", + id: "integration-1", + inboxId: "inbox-1", + ownerId: "owner-1", + tx: tx as never, + }) + + expect(callOrder).toEqual(["delete", "inbox-disconnect"]) + }) +}) diff --git a/packages/business/__tests__/integration-tiktok-service.test.ts b/packages/business/__tests__/integration-tiktok-service.test.ts new file mode 100644 index 0000000000..e64de4cdf9 --- /dev/null +++ b/packages/business/__tests__/integration-tiktok-service.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockConnectChannelIntegration, + mockDelete, + mockDisconnect, + mockInsert, + mockInsertReturning, + mockOnConflictDoUpdate, + mockTransaction, +} = vi.hoisted(() => { + const mockDeleteWhere = vi.fn(async () => undefined) + const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) + const mockInsertReturning = vi.fn(async () => [{ id: "integration-1" }]) + const mockOnConflictDoUpdate = vi.fn(() => ({ + returning: mockInsertReturning, + })) + const mockInsertValues = vi.fn(() => ({ + onConflictDoUpdate: mockOnConflictDoUpdate, + })) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + + return { + mockConnectChannelIntegration: vi.fn(), + mockDelete, + mockDisconnect: vi.fn(async () => undefined), + mockInsert, + mockInsertReturning, + mockOnConflictDoUpdate, + mockTransaction: vi.fn(async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + delete: mockDelete, + transaction: mockTransaction, + }, + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + findOrFail: vi.fn(), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationTiktokModel: { id: "id", openId: "openId" }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: () => "integration-1", +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: mockConnectChannelIntegration, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mockDisconnect }, +})) + +const { tiktokIntegrationService } = await import( + "../src/integration-tiktok/service" +) + +describe("tiktokIntegrationService.connect", () => { + beforeEach(() => { + vi.clearAllMocks() + mockInsertReturning.mockResolvedValue([{ id: "integration-1" }]) + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ) + }) + + test("upserts on openId and returns the persisted id from returning", async () => { + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: (inboxId: string) => Promise + }) => { + const integration = await props.insertIntegration("inbox-1") + return { wasCreated: true, integration } + }, + ) + + const result = await tiktokIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + openId: "open-1", + username: "user1", + displayName: "User One", + auth: { token: "x" }, + }) + + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: ["openId"], + set: expect.objectContaining({ + auth: { token: "x" }, + name: "User One", + tokenRefreshError: null, + }), + }), + ) + expect(result.integration).toEqual({ id: "integration-1" }) + expect(result.wasCreated).toBe(true) + }) +}) + +describe("tiktokIntegrationService.disconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("deletes the integration row then calls inboxService.disconnect", async () => { + const callOrder: string[] = [] + const tx = { + delete: vi.fn(() => { + callOrder.push("delete") + return { where: vi.fn(async () => undefined) } + }), + } + mockDisconnect.mockImplementation(() => { + callOrder.push("inbox-disconnect") + return Promise.resolve() + }) + + await tiktokIntegrationService.disconnect({ + workspaceId: "ws-1", + id: "integration-1", + inboxId: "inbox-1", + ownerId: "owner-1", + tx: tx as never, + }) + + expect(callOrder).toEqual(["delete", "inbox-disconnect"]) + expect(mockDisconnect).toHaveBeenCalledWith({ + inboxId: "inbox-1", + ownerId: "owner-1", + workspaceId: "ws-1", + reason: "manual", + tx, + }) + }) +}) diff --git a/packages/business/__tests__/integration-webchat-service.test.ts b/packages/business/__tests__/integration-webchat-service.test.ts new file mode 100644 index 0000000000..7e81b4d54e --- /dev/null +++ b/packages/business/__tests__/integration-webchat-service.test.ts @@ -0,0 +1,273 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockCount, + mockCreateId, + mockFindFirst, + mockFindMany, + mockInboxCreate, + mockInsert, + mockParsePagination, + mockRelationsFilterToSQL, + mockTransaction, + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockWorkspaceCreate, + mockWorkspaceFindOrFail, +} = vi.hoisted(() => { + let createIdCallCount = 0 + const mockInsertReturning = vi.fn(async () => [{ id: "webchat-1" }]) + const mockInsertValues = vi.fn(() => ({ returning: mockInsertReturning })) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + const mockUpdateWhere = vi.fn(async () => undefined) + const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) + const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) + + return { + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockCount: vi.fn(async () => 25), + mockCreateId: vi.fn(() => `id-${++createIdCallCount}`), + mockFindFirst: vi.fn(), + mockFindMany: vi.fn(async () => []), + mockInboxCreate: vi.fn(async () => ({ + inbox: { id: "inbox-1" }, + wasCreated: true, + })), + mockInsert, + mockParsePagination: vi.fn(), + mockRelationsFilterToSQL: vi.fn(), + mockTransaction: vi.fn(async (callback: (tx: unknown) => unknown) => + callback({ insert: mockInsert }), + ), + mockWorkspaceCreate: vi.fn(async () => ({ + id: "ws-new", + ownerId: "user-1", + })), + mockWorkspaceFindOrFail: vi.fn(async () => ({ + id: "ws-1", + ownerId: "owner-1", + })), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions })), + db: { + $count: mockCount, + query: { + integrationWebchatModel: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + }, + transaction: mockTransaction, + update: mockUpdate, + }, + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + findOrFail: vi.fn(async ({ where }: { where: unknown }) => { + const row = await mockFindFirst(where) + if (!row) { + throw new Error("not found") + } + return row + }), + relationsFilterToSQL: mockRelationsFilterToSQL, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationWebchatModel: { id: "id", workspaceId: "workspaceId" }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + parsePagination: mockParsePagination, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { create: mockInboxCreate, disconnect: vi.fn() }, +})) + +vi.mock("../src/template/installed-resource.service", () => ({ + assertDeletable: vi.fn(async () => undefined), +})) + +vi.mock("../src/workspace", () => ({ + workspaceService: { + create: mockWorkspaceCreate, + findOrFail: mockWorkspaceFindOrFail, + }, +})) + +const { integrationWebchatService } = await import( + "../src/integration-webchat/service" +) + +const baseData = { + name: "My Webchat", + auth: {}, + enable: true, + authorizedDomains: [], + conversationStarters: [], + persistentMenus: [], + brandColor: "#000000", + hideHeader: false, + showLogo: true, + hideMessageInput: false, + customCss: null, + welcomeFlowId: null, +} + +describe("integrationWebchatService.createWithWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks() + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ insert: mockInsert }), + ) + mockWorkspaceFindOrFail.mockResolvedValue({ + id: "ws-1", + ownerId: "owner-1", + } as never) + mockWorkspaceCreate.mockResolvedValue({ + id: "ws-new", + ownerId: "user-1", + } as never) + mockInboxCreate.mockResolvedValue({ + inbox: { id: "inbox-1" }, + wasCreated: true, + } as never) + }) + + test("creates a workspace only when workspaceId is absent and reports createdWorkspace correctly", async () => { + const withWorkspace = await integrationWebchatService.createWithWorkspace({ + workspaceId: "ws-1", + createdBy: "user-1", + workspaceName: "My Chatbot", + data: baseData, + }) + expect(mockWorkspaceCreate).not.toHaveBeenCalled() + expect(withWorkspace.createdWorkspace).toBe(false) + expect(withWorkspace.workspaceId).toBe("ws-1") + + vi.clearAllMocks() + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ insert: mockInsert }), + ) + mockWorkspaceCreate.mockResolvedValue({ + id: "ws-new", + ownerId: "user-1", + } as never) + mockInboxCreate.mockResolvedValue({ + inbox: { id: "inbox-1" }, + wasCreated: true, + } as never) + + const withoutWorkspace = + await integrationWebchatService.createWithWorkspace({ + createdBy: "user-1", + workspaceName: "My Chatbot", + data: baseData, + }) + expect(mockWorkspaceCreate).toHaveBeenCalledTimes(1) + expect(withoutWorkspace.createdWorkspace).toBe(true) + expect(withoutWorkspace.workspaceId).toBe("ws-new") + }) +}) + +describe("integrationWebchatService.list", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("computes pageCount as ceil(total/limit)", async () => { + mockParsePagination.mockReturnValue({ limit: 10, offset: 0 }) + mockCount.mockResolvedValue(25) + mockFindMany.mockResolvedValue([]) + + const result = await integrationWebchatService.list({ + workspaceId: "ws-1", + page: 1, + perPage: 10, + }) + + expect(result.pageCount).toBe(3) + }) + + test("returns pageCount 1 when unpaginated", async () => { + mockParsePagination.mockReturnValue(null) + mockFindMany.mockResolvedValue([]) + + const result = await integrationWebchatService.list({ + workspaceId: "ws-1", + }) + + expect(result.pageCount).toBe(1) + expect(mockCount).not.toHaveBeenCalled() + }) +}) + +describe("integrationWebchatService.findByIdForWorkspaceOrNull", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns undefined instead of throwing when no row matches", async () => { + mockFindFirst.mockResolvedValue(undefined) + + const result = await integrationWebchatService.findByIdForWorkspaceOrNull({ + id: "missing", + workspaceId: "ws-1", + }) + + expect(result).toBeUndefined() + }) +}) + +describe("integrationWebchatService.update", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The action layer pre-checks ownership, but the method takes a + // `workspaceId` and must scope on it itself — a mismatched (id, workspaceId) + // pair must update nothing rather than another workspace's row. + test("scopes the update by workspaceId as well as id", async () => { + await integrationWebchatService.update({ + workspaceId: "ws-1", + id: "webchat-1", + data: { name: "Support" }, + }) + + expect(mockUpdateWhere).toHaveBeenCalledWith({ + conditions: [ + { field: "id", value: "webchat-1" }, + { field: "workspaceId", value: "ws-1" }, + ], + }) + }) + + // `workspaceId` scopes the row; writing it would let a mismatched pair move + // the webchat into another workspace. + test("never writes workspaceId into the update payload", async () => { + await integrationWebchatService.update({ + workspaceId: "ws-1", + id: "webchat-1", + data: { name: "Support" }, + }) + + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.not.objectContaining({ workspaceId: expect.anything() }), + ) + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ name: "Support" }), + ) + }) +}) diff --git a/packages/business/__tests__/integration-zalo-service.test.ts b/packages/business/__tests__/integration-zalo-service.test.ts new file mode 100644 index 0000000000..9e5d9aa4ba --- /dev/null +++ b/packages/business/__tests__/integration-zalo-service.test.ts @@ -0,0 +1,291 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockConnectChannelIntegration, + mockDelete, + mockDisconnect, + mockEnqueueChannelScan, + mockInsertReturning, + mockInsert, + mockInvalidateCacheByTags, + mockTransaction, +} = vi.hoisted(() => { + const mockDeleteWhere = vi.fn(async () => undefined) + const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) + const mockInsertReturning = vi.fn(async () => [{ id: "integration-1" }]) + const mockInsertValues = vi.fn(() => ({ returning: mockInsertReturning })) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + + return { + mockConnectChannelIntegration: vi.fn(), + mockDelete, + mockDisconnect: vi.fn(async () => undefined), + mockEnqueueChannelScan: vi.fn(async () => undefined), + mockInsertReturning, + mockInsert, + mockInvalidateCacheByTags: vi.fn(async () => undefined), + mockTransaction: vi.fn(async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions })), + db: { + delete: mockDelete, + transaction: mockTransaction, + }, + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + findOrFail: vi.fn(), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { zalo: "zalo" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + integrationZaloModel: { id: "id", openId: "openId" }, + tagChannelModel: { + channelType: "channelType", + integrationId: "integrationId", + }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: mockInvalidateCacheByTags, +})) + +const dispatchAuditRecord = vi.fn() +vi.mock("../src/audit/dispatcher", () => ({ dispatchAuditRecord })) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: mockConnectChannelIntegration, +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { disconnect: mockDisconnect }, +})) + +vi.mock("../src/tag/sync.service", () => ({ + tagSyncService: { enqueueChannelScan: mockEnqueueChannelScan }, +})) + +vi.mock("../src/logger", () => ({ + logger: { error: vi.fn(), warn: vi.fn() }, +})) + +const { zaloIntegrationService } = await import( + "../src/integration-zalo/service" +) + +describe("zaloIntegrationService.connect", () => { + beforeEach(() => { + vi.clearAllMocks() + // `clearAllMocks` clears calls but keeps implementations, so tests that + // install a failing/slow stub below must not leak into their neighbours. + mockInvalidateCacheByTags.mockResolvedValue(undefined) + mockEnqueueChannelScan.mockResolvedValue(undefined) + mockInsertReturning.mockResolvedValue([{ id: "integration-1" }]) + mockTransaction.mockImplementation( + async (callback: (tx: unknown) => unknown) => + callback({ delete: mockDelete, insert: mockInsert }), + ) + }) + + test("invalidates the zalos cache tag exactly once and enqueues the channel scan when an integration id was produced", async () => { + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: ( + inboxId: string, + wasCreated: boolean, + ) => Promise + }) => { + await props.insertIntegration("inbox-1", true) + return { wasCreated: true } + }, + ) + + const result = await zaloIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + oaId: "oa-1", + name: "My OA", + auth: { token: "x" }, + }) + + expect(mockInvalidateCacheByTags).toHaveBeenCalledTimes(1) + expect(mockInvalidateCacheByTags).toHaveBeenCalledWith([ + "workspaces:ws-1#zalos", + ]) + expect(result.wasCreated).toBe(true) + }) + + // The cache invalidation is a Redis round-trip; if it is not awaited the + // OAuth callback redirects before the tag is cleared and the channels page + // renders a stale list that omits the OA just connected. + test("awaits the cache invalidation before returning", async () => { + // The stub stays pending until `release()` is called, so `connect` can only + // settle if it actually awaits it. A fire-and-forget call would resolve the + // promise below while the invalidation is still in flight. + let release: () => void = () => undefined + let invalidationSettled = false + mockInvalidateCacheByTags.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + invalidationSettled = true + resolve() + } + }), + ) + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: ( + inboxId: string, + wasCreated: boolean, + ) => Promise + }) => { + await props.insertIntegration("inbox-1", true) + return { wasCreated: true } + }, + ) + + let connectResolved = false + const connecting = zaloIntegrationService + .connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + oaId: "oa-1", + name: "My OA", + auth: {}, + }) + .then((result) => { + connectResolved = true + return result + }) + + // Let every already-resolved microtask drain; `connect` must still be + // parked on the pending invalidation. + await new Promise((resolve) => setImmediate(resolve)) + expect(connectResolved).toBe(false) + + release() + await connecting + + expect(invalidationSettled).toBe(true) + }) + + // The row is already committed by this point, so a queue outage must not + // fail the connect — the caller still has to write its audit record. + test("survives a channel-scan enqueue failure", async () => { + mockEnqueueChannelScan.mockRejectedValue(new Error("redis down")) + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: ( + inboxId: string, + wasCreated: boolean, + ) => Promise + }) => { + await props.insertIntegration("inbox-1", true) + return { wasCreated: true } + }, + ) + + const result = await zaloIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + oaId: "oa-1", + name: "My OA", + auth: {}, + }) + + expect(result).toEqual({ + integrationId: "integration-1", + wasCreated: true, + }) + }) + + test("does not enqueue a channel scan when no integration id was produced", async () => { + mockConnectChannelIntegration.mockResolvedValue({ wasCreated: true }) + + await zaloIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + oaId: "oa-1", + name: "My OA", + auth: {}, + }) + + expect(mockEnqueueChannelScan).not.toHaveBeenCalled() + }) + + test("throws channelDuplicatedException when insertIntegration receives wasCreated === false", async () => { + mockConnectChannelIntegration.mockImplementation( + async (props: { + insertIntegration: ( + inboxId: string, + wasCreated: boolean, + ) => Promise + }) => { + await props.insertIntegration("inbox-1", false) + return { wasCreated: false } + }, + ) + + await expect( + zaloIntegrationService.connect({ + workspaceId: "ws-1", + ownerId: "owner-1", + oaId: "oa-1", + name: "My OA", + auth: {}, + }), + ).rejects.toMatchObject({ code: "channelDuplicated" }) + }) +}) + +describe("zaloIntegrationService.disconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("deletes tagChannel rows before the integration row and calls inboxService.disconnect with the same tx", async () => { + const callOrder: string[] = [] + const tx = { + delete: vi.fn((table: { integrationId?: string }) => { + callOrder.push( + table?.integrationId ? "delete-tagChannel" : "delete-integration", + ) + return { where: vi.fn(async () => undefined) } + }), + } + mockDisconnect.mockImplementation(() => { + callOrder.push("inbox-disconnect") + return Promise.resolve() + }) + + await zaloIntegrationService.disconnect({ + workspaceId: "ws-1", + id: "integration-1", + inboxId: "inbox-1", + ownerId: "owner-1", + tx: tx as never, + }) + + expect(callOrder).toEqual([ + "delete-tagChannel", + "delete-integration", + "inbox-disconnect", + ]) + expect(mockDisconnect).toHaveBeenCalledWith({ + inboxId: "inbox-1", + ownerId: "owner-1", + workspaceId: "ws-1", + reason: "manual", + tx, + }) + }) +}) diff --git a/apps/builder/src/features/common/lib/__tests__/map-stats-contact-row.test.ts b/packages/business/__tests__/map-stats-contact-row.test.ts similarity index 92% rename from apps/builder/src/features/common/lib/__tests__/map-stats-contact-row.test.ts rename to packages/business/__tests__/map-stats-contact-row.test.ts index bbafdcab5f..6d35086d72 100644 --- a/apps/builder/src/features/common/lib/__tests__/map-stats-contact-row.test.ts +++ b/packages/business/__tests__/map-stats-contact-row.test.ts @@ -1,7 +1,7 @@ import type { ContactEventData } from "@chatbotx.io/analytics/schemas" -import type { ContactInboxWithAnalytics } from "@chatbotx.io/business" import { describe, expect, test } from "vitest" -import { mapStatsContactRow } from "../map-stats-contact-row" +import { mapStatsContactRow } from "../src/contact-inbox/map-stats-contact-row" +import type { ContactInboxWithAnalytics } from "../src/contact-inbox/service" const eventData: ContactEventData = { contactId: "contact-1", diff --git a/packages/business/__tests__/sequence-service-list-step-contacts.test.ts b/packages/business/__tests__/sequence-service-list-step-contacts.test.ts new file mode 100644 index 0000000000..ec258ce9f5 --- /dev/null +++ b/packages/business/__tests__/sequence-service-list-step-contacts.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getContacts: vi.fn(), + findManyByIds: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn(), + db: {}, + eq: vi.fn(), + findOrFail: vi.fn(), + isUniqueViolationError: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + sequenceModel: {}, + sequenceStepModel: {}, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + sequenceRepository: {}, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + getPaginationWithDefaults: vi.fn(), +})) + +vi.mock("@chatbotx.io/analytics", () => ({ + sequenceAnalyticsService: { getContacts: mocks.getContacts }, +})) + +vi.mock("../src/contact-inbox/service", () => ({ + contactInboxService: { findManyByIds: mocks.findManyByIds }, +})) + +const { sequenceService } = await import("../src/sequence/service") + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("sequenceService.listStepContactsPage", () => { + test("returns an empty page without a contact-inbox lookup when there are no matching recipients", async () => { + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: [], + contactEventMap: new Map(), + }) + + const result = await sequenceService.listStepContactsPage({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 5, + page: 1, + perPage: 20, + }) + + expect(result).toEqual({ data: [], total: 5, pageCount: 1 }) + expect(mocks.findManyByIds).not.toHaveBeenCalled() + }) + + test("defaults a falsy total to 0", async () => { + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: [], + contactEventMap: new Map(), + }) + + const result = await sequenceService.listStepContactsPage({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 0, + page: 1, + perPage: 20, + }) + + expect(result.total).toBe(0) + expect(result.pageCount).toBe(0) + }) + + test("joins recipient events with contact-inbox details, defaulting a missing conversationId to an empty string", async () => { + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1", "ci-no-conversation"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + [ + "ci-no-conversation", + { + contactId: "contact-2", + occurredAt: "2026-01-02T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + }) + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + avatar: null, + }, + }, + { + id: "ci-no-conversation", + sourceId: "src-2", + channel: "whatsapp", + conversation: null, + contact: { + id: "contact-2", + firstName: "Bea", + lastName: null, + fullName: "Bea", + avatar: null, + }, + }, + ]) + + const result = await sequenceService.listStepContactsPage({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 2, + page: 1, + perPage: 20, + }) + + // Both rows are kept — a contact inbox with no conversation still + // belongs in the page; this is the sequences-side behaviour change from + // the previous per-handler implementation, which dropped such rows. + expect(result.data).toHaveLength(2) + expect(result.total).toBe(2) + expect(result.pageCount).toBe(1) + expect(result.data[0]).toMatchObject({ + contactId: "contact-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }) + expect(result.data[1]).toMatchObject({ + contactId: "contact-2", + contactInboxId: "ci-no-conversation", + conversationId: "", + }) + }) + + test("drops a recipient whose contact-inbox no longer resolves, leaving pageCount driven by the caller-supplied total", async () => { + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1", "ci-gone"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + [ + "ci-gone", + { + contactId: "contact-gone", + occurredAt: "2026-01-02T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + }) + // `getContacts` scopes by the sequence's workspace while `findManyByIds` + // scopes by `Contact.workspaceId`, so a contact deleted or moved out of + // the workspace after the event fired is counted in `total` but has no + // row here. + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: null, + fullName: "Ada", + avatar: null, + }, + }, + ]) + + const result = await sequenceService.listStepContactsPage({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 2, + page: 1, + perPage: 20, + }) + + // Unresolvable rows are dropped rather than emitted as nulls, and + // `pageCount` stays anchored to the caller-supplied total — so + // `data.length` can be shorter than the total implies. + expect(result.data).toHaveLength(1) + expect(result.total).toBe(2) + expect(result.pageCount).toBe(1) + expect(result.data[0]).toMatchObject({ + contactId: "contact-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }) + }) + + test("threads workspaceId through the analytics lookup and contact-inbox fetch", async () => { + mocks.getContacts.mockResolvedValueOnce({ + contactInboxIds: ["ci-1"], + contactEventMap: new Map([ + [ + "ci-1", + { + contactId: "contact-1", + occurredAt: "2026-01-01T00:00:00.000Z", + errorContent: null, + }, + ], + ]), + }) + mocks.findManyByIds.mockResolvedValueOnce([ + { + id: "ci-1", + sourceId: "src-1", + channel: "whatsapp", + conversation: { id: "conv-1" }, + contact: { + id: "contact-1", + firstName: "Ada", + lastName: null, + fullName: "Ada", + avatar: null, + }, + }, + ]) + + await sequenceService.listStepContactsPage({ + workspaceId: "ws-1", + sequenceId: "seq-1", + stepId: "step-1", + eventType: "message:sent", + total: 1, + page: 1, + perPage: 20, + }) + + expect(mocks.getContacts).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws-1" }), + ) + expect(mocks.findManyByIds).toHaveBeenCalledWith({ + workspaceId: "ws-1", + ids: ["ci-1"], + }) + }) +}) diff --git a/packages/business/__tests__/sequence-service.test.ts b/packages/business/__tests__/sequence-service.test.ts index 653a3bb5c8..d7f95b5037 100644 --- a/packages/business/__tests__/sequence-service.test.ts +++ b/packages/business/__tests__/sequence-service.test.ts @@ -68,6 +68,15 @@ const { } }) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + +vi.mock("../src/contact-inbox/service", () => ({ + contactInboxService: { findManyByIds: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/client", () => ({ db: { insert: (model: unknown) => diff --git a/packages/business/src/broadcast/__tests__/broadcast-targets.test.ts b/packages/business/src/broadcast/__tests__/broadcast-targets.test.ts index cecd8de607..0033c60319 100644 --- a/packages/business/src/broadcast/__tests__/broadcast-targets.test.ts +++ b/packages/business/src/broadcast/__tests__/broadcast-targets.test.ts @@ -19,6 +19,10 @@ const mocks = vi.hoisted(() => ({ dispatchAuditRecord: vi.fn().mockResolvedValue(undefined), })) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags: vi.fn() })) vi.mock("../../inbox/service", () => ({ diff --git a/packages/business/src/broadcast/__tests__/broadcast.service.test.ts b/packages/business/src/broadcast/__tests__/broadcast.service.test.ts index c9a2dadfd7..04877c333b 100644 --- a/packages/business/src/broadcast/__tests__/broadcast.service.test.ts +++ b/packages/business/src/broadcast/__tests__/broadcast.service.test.ts @@ -18,6 +18,10 @@ const mocks = vi.hoisted(() => ({ })), })) +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, +})) + vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags: vi.fn(), })) diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index d572108c8b..2a387f451d 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -1,3 +1,5 @@ +import { broadcastAnalyticsService } from "@chatbotx.io/analytics" +import type { BroadcastEventType } from "@chatbotx.io/analytics/schemas" import { and, asc, @@ -83,6 +85,11 @@ import { import { createId } from "@chatbotx.io/utils" import { startOfMinute } from "date-fns" import { BaseService } from "../base.service" +import { + mapStatsContactRow, + type StatsContactRow, +} from "../contact-inbox/map-stats-contact-row" +import { contactInboxService } from "../contact-inbox/service" import { ChatbotXException, notFoundException } from "../errors" import { inboxService } from "../inbox/service" import type { @@ -571,6 +578,80 @@ class BroadcastService extends BaseService { return rows.map((row) => row.id) } + /** + * One page of a broadcast's recipients for a given delivery event, with + * contact display fields attached — shared by the public and private + * "list broadcast contacts" routes so both call the same orchestration + * (existence check → analytics lookup → contact-inbox fetch → row shape). + * `conversationId` is always included: it is a superset the public + * response schema simply doesn't declare (zod strips undeclared keys), so + * one method safely serves both callers. + */ + async listContactsPage(input: { + workspaceId: string + broadcastId: string + eventType: BroadcastEventType + page: number + perPage: number + }): Promise<{ + data: (StatsContactRow & { conversationId: string })[] + total: number + pageCount: number + }> { + const { workspaceId, broadcastId, eventType, page, perPage } = input + + const [existingId] = await this.listExistingIds({ + workspaceId, + ids: [broadcastId], + }) + if (!existingId) { + throw notFoundException("Broadcast not found") + } + + const { contactInboxIds, contactEventMap, total } = + await broadcastAnalyticsService.getContacts({ + workspaceId, + broadcastId, + eventType, + page, + perPage, + }) + const pageCount = Math.ceil(total / perPage) + + if (contactInboxIds.length === 0) { + return { data: [], total, pageCount } + } + + const contactInboxes = await contactInboxService.findManyByIds({ + workspaceId, + ids: contactInboxIds, + }) + const contactMap = new Map(contactInboxes.map((c) => [c.id, c])) + + const data = contactInboxIds + .map((contactInboxId) => { + const row = mapStatsContactRow( + contactInboxId, + contactEventMap.get(contactInboxId), + contactMap.get(contactInboxId), + ) + if (!row) { + return null + } + return { + ...row, + conversationId: + contactMap.get(contactInboxId)?.conversation?.id ?? "", + } + }) + .filter( + (row): row is StatsContactRow & { conversationId: string } => + row !== null, + ) + + return { data, total, pageCount } + } + /** * Read-only send-path guard: is `broadcastId` still eligible to receive * sends right now? The template send handlers and the flow dispatch guard diff --git a/packages/business/src/contact-inbox/index.ts b/packages/business/src/contact-inbox/index.ts index 70cc498083..407fcc9daf 100644 --- a/packages/business/src/contact-inbox/index.ts +++ b/packages/business/src/contact-inbox/index.ts @@ -1,2 +1,3 @@ export * from "./last-user-input" +export * from "./map-stats-contact-row" export * from "./service" diff --git a/apps/builder/src/features/common/lib/map-stats-contact-row.ts b/packages/business/src/contact-inbox/map-stats-contact-row.ts similarity index 78% rename from apps/builder/src/features/common/lib/map-stats-contact-row.ts rename to packages/business/src/contact-inbox/map-stats-contact-row.ts index 2bb28c127c..3ecc47c09a 100644 --- a/apps/builder/src/features/common/lib/map-stats-contact-row.ts +++ b/packages/business/src/contact-inbox/map-stats-contact-row.ts @@ -1,18 +1,16 @@ import type { ContactEventData } from "@chatbotx.io/analytics/schemas" -import type { ContactInboxWithAnalytics } from "@chatbotx.io/business" import type { ChannelType } from "@chatbotx.io/database/partials" +import type { ContactInboxWithAnalytics } from "./service" // Shared row-shape across every "stats contacts" route that pairs a // `ContactEventData` with its `ContactInboxWithAnalytics` per -// `contactInboxId` — broadcasts (`privateListBroadcastContactsAPI`, -// `broadcastsPublicRouter.listContacts`) and sequences -// (`privateListSequenceStepContactsAPI`). All feed the same +// `contactInboxId` — broadcasts (`broadcastService.listContactsPage`) and +// sequences (`sequenceService.listStepContactsPage`). All feed the same // `StatsContactsDialog` → `addContactTagAction` / // `bulkTagStatsContactsAction` path, which requires `contactId` to be the // real **Contact** id (`eventData.contactId`) — NOT the ContactInbox id // (`contactInbox.id`). A caller that emits the wrong one tags the wrong -// contact silently; see the broadcasts/sequences private-route call sites -// for how `conversationId` (only some callers need it) is layered on top. +// contact silently. export type StatsContactRow = { contactId: string contactInboxId: string diff --git a/packages/business/src/integration-smtp/service.ts b/packages/business/src/integration-smtp/service.ts index 3ddb69e295..00236d013d 100644 --- a/packages/business/src/integration-smtp/service.ts +++ b/packages/business/src/integration-smtp/service.ts @@ -1,22 +1,26 @@ -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import type { DatabaseClient } from "@chatbotx.io/database/client" +import { and, 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 type { + InboxModel, + 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" +import type { IntegrationSmtpResource } from "./schema" /** - * 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`. + * Mirrors `SmtpAuthValue` from `@chatbotx.io/integration-smtp` without + * importing that package into business (it would pull `nodemailer` + + * `next-intl` transitively into every business consumer, including the + * worker). Host/port resolution against `smtpHostMap` stays in the builder + * and is passed in already resolved. */ -export type SmtpAuthValue = { +type SmtpAuthInput = { authType: "custom" provider: string host: string @@ -25,17 +29,6 @@ export type SmtpAuthValue = { 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({ where, @@ -47,52 +40,49 @@ class IntegrationSmtpService extends BaseService { }) } - listByWorkspaceId(workspaceId: string) { - return db.query.integrationSmtpModel.findMany({ - where: { workspaceId }, - orderBy: { createdAt: "desc" }, - }) - } - - findByIdForWorkspace(props: { id: string; workspaceId: string }) { + findByIdForWorkspace(props: { + id: string + workspaceId: string + }): Promise { return findOrFail({ table: integrationSmtpModel, - where: props, + where: { id: props.id, workspaceId: props.workspaceId }, 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( + async listByWorkspace( workspaceId: string, - input: CreateSmtpInput, - ): Promise<{ id: string }> { - const { host, port } = input - - const workspace = await workspaceService.find({ - where: { id: workspaceId }, + ): Promise { + const data = await db.query.integrationSmtpModel.findMany({ + where: { workspaceId }, + orderBy: { + createdAt: "desc", + }, }) - if (!workspace) { - throw new ChatbotXException("Workspace not found") - } + + return data.map(({ id, name, fromAddress }) => ({ + id, + name, + fromAddress, + })) + } + + async connect(input: { + workspaceId: string + ownerId: string + name: string + fromAddress: string + auth: SmtpAuthInput + }): Promise<{ inbox: InboxModel; wasCreated: boolean }> { + const { workspaceId, ownerId, name, fromAddress, auth } = input const { inbox, wasCreated } = await db.transaction(async (tx) => { const smtpId = createId() - const name = input.username return await connectChannelIntegration({ tx, - ownerId: workspace.ownerId, + ownerId, inboxData: { id: smtpId, workspaceId, @@ -106,101 +96,80 @@ class IntegrationSmtpService extends BaseService { name, workspaceId, inboxId, - fromAddress: input.fromAddress, - auth: { - authType: "custom" as const, - provider: input.provider, - username: input.username, - password: input.password, - host, - port, - }, + fromAddress, + auth, }) }, }) }) - if (wasCreated) { - await this.audit("connect", `connected a new SMTP channel (#${inbox.id})`) - } - - return inbox + return { inbox, wasCreated } } - 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 + async update(input: { + workspaceId: string + id: string + auth: SmtpAuthInput + name: string + fromAddress: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, auth, name, fromAddress, tx = db } = input + + // Scoped by workspace as well as id: callers already pre-check ownership + // via `findByIdForWorkspace`, but this method accepts a `workspaceId` and + // must honour it rather than trusting every future caller to guard first. + const [updated] = await tx .update(integrationSmtpModel) - .set({ auth: updatedAuth, name, fromAddress }) - .where(eq(integrationSmtpModel.id, integration.id)) + .set({ auth, name, fromAddress }) + .where( + and( + eq(integrationSmtpModel.id, id), + eq(integrationSmtpModel.workspaceId, workspaceId), + ), + ) .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") + throw new ChatbotXException("SMTP integration not found") } 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 + async disconnect(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, inboxId, ownerId, tx } = input + + const run = async (client: DatabaseClient) => { + await client .delete(integrationSmtpModel) - .where(eq(integrationSmtpModel.id, integration.id)) + .where( + and( + eq(integrationSmtpModel.id, id), + eq(integrationSmtpModel.workspaceId, workspaceId), + ), + ) await inboxService.disconnect({ - inboxId: integration.inboxId, - ownerId: workspace.ownerId, + inboxId, + ownerId, workspaceId, reason: "manual", - tx, + tx: client, }) - }) + } - await this.audit( - "disconnect", - `disconnected the SMTP channel (#${integration.id})`, - ) + if (tx) { + await run(tx) + return + } + await db.transaction(run) } } export const integrationSmtpService = new IntegrationSmtpService() diff --git a/packages/business/src/integration-telegram/service.ts b/packages/business/src/integration-telegram/service.ts index 654aba5c9b..7eb14a93cc 100644 --- a/packages/business/src/integration-telegram/service.ts +++ b/packages/business/src/integration-telegram/service.ts @@ -1,24 +1,22 @@ +import type { DatabaseClient } from "@chatbotx.io/database/client" import { - type DatabaseClient, + and, db, eq, findOrFail, + isDatabaseError, } from "@chatbotx.io/database/client" +import { integrationTypes } from "@chatbotx.io/database/partials" 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 { ChatbotXException } from "../errors" import { connectChannelIntegration } from "../inbox/connect-channel" +import { inboxService } from "../inbox/service" +import { workspaceService } from "../workspace" -export type ConnectTelegramInput = { - tx: DatabaseClient - ownerId: string - workspaceId: string - botId: string - botUsername: string - botToken: string -} +const UNIQUE_VIOLATION_CODE = "23505" class TelegramIntegrationService extends BaseService { findByInboxIdForWorkspace(props: { inboxId: string; workspaceId: string }) { @@ -28,72 +26,150 @@ class TelegramIntegrationService extends BaseService { }) } - findByWorkspaceIdAndId(props: { workspaceId: string; id: string }) { + findByIdForWorkspace(props: { id: string; workspaceId: string }) { return findOrFail({ table: integrationTelegramModel, - where: { workspaceId: props.workspaceId, id: props.id }, + where: { id: props.id, workspaceId: props.workspaceId }, message: "Integration Telegram not found", }) } - listByWorkspaceId( + async listByWorkspace( where: Partial>, - ) { - return db.query.integrationTelegramModel.findMany({ + ): Promise { + return await db.query.integrationTelegramModel.findMany({ where, - orderBy: { createdAt: "asc" }, + orderBy: { + createdAt: "asc", + }, }) } - findByWorkspaceId(workspaceId: string) { - return db.query.integrationTelegramModel.findFirst({ - where: { workspaceId }, - }) + async findByBotId(botId: string): Promise { + return ( + (await db.query.integrationTelegramModel.findFirst({ + where: { botId }, + })) ?? null + ) } - /** No auth check — for use by the webhook handler only. */ - findByBotId(botId: string) { - return db.query.integrationTelegramModel.findFirst({ - where: { botId }, - }) - } + async connect(input: { + workspaceId?: string + ownerId: string + createdBy: string + botId: string + botUsername: string + botToken: string + onConnected: (ctx: { integrationId: string }) => Promise + }): Promise<{ + workspaceId: string + createdWorkspace: boolean + wasCreated: boolean + integrationId: string + }> { + const { ownerId, createdBy, botId, botUsername, botToken, onConnected } = + input + let { workspaceId } = input - async connect(input: ConnectTelegramInput) { - const auth: SecretTextAuthValue = { - authType: "secretText", - secretText: input.botToken, - } - const integrationId = createId() + try { + return await db.transaction(async (tx) => { + const auth = { + authType: "secretText" as const, + secretText: botToken, + } + let createdWorkspace = false + let effectiveOwnerId = ownerId - 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, + if (!workspaceId) { + const workspace = await workspaceService.create({ + tx, + createdBy, + data: { + name: botUsername, + timezone: "UTC", + ownerId: createdBy, + }, + }) + workspaceId = workspace.id + effectiveOwnerId = createdBy + createdWorkspace = true + } + + const integrationId = createId() + const { wasCreated } = await connectChannelIntegration({ + tx, + ownerId: effectiveOwnerId, + inboxData: { + id: createId(), + workspaceId, + name: botUsername, + channel: integrationTypes.enum.telegram, + sourceId: botId, + }, + insertIntegration: async (inboxId) => { + await tx.insert(integrationTelegramModel).values({ + id: integrationId, + inboxId, + workspaceId: workspaceId as string, + botId, + name: botUsername, + auth, + }) + }, }) - }, - }) - return { integrationId, wasCreated } + await onConnected({ integrationId }) + + return { + workspaceId, + createdWorkspace, + wasCreated, + integrationId, + } + }) + } catch (error) { + if ( + isDatabaseError(error) && + error.cause.code === UNIQUE_VIOLATION_CODE + ) { + throw new ChatbotXException("Bot already connected") + } + throw error + } } - async disconnect(props: { id: string; tx: DatabaseClient }) { - await props.tx - .delete(integrationTelegramModel) - .where(eq(integrationTelegramModel.id, props.id)) + async disconnect(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, inboxId, ownerId, tx } = input + + const run = async (client: DatabaseClient) => { + await client + .delete(integrationTelegramModel) + .where( + and( + eq(integrationTelegramModel.id, id), + eq(integrationTelegramModel.workspaceId, workspaceId), + ), + ) + await inboxService.disconnect({ + inboxId, + ownerId, + workspaceId, + reason: "manual", + tx: client, + }) + } + + if (tx) { + await run(tx) + return + } + await db.transaction(run) } } diff --git a/packages/business/src/integration-tiktok/service.ts b/packages/business/src/integration-tiktok/service.ts index 0425345440..9045045b67 100644 --- a/packages/business/src/integration-tiktok/service.ts +++ b/packages/business/src/integration-tiktok/service.ts @@ -1,25 +1,11 @@ -import { - type DatabaseClient, - db, - eq, - findOrFail, - inArray, -} from "@chatbotx.io/database/client" +import type { DatabaseClient } from "@chatbotx.io/database/client" +import { and, 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 -} +import { inboxService } from "../inbox/service" class TiktokIntegrationService extends BaseService { findById(props: { id: string; workspaceId: string }) { @@ -30,67 +16,6 @@ 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({ @@ -128,6 +53,112 @@ class TiktokIntegrationService extends BaseService { .set({ tokenRefreshError: error }) .where(eq(integrationTiktokModel.id, id)) } + + async listByWorkspace( + where: Partial>, + ): Promise { + return await db.query.integrationTiktokModel.findMany({ + where, + orderBy: { + createdAt: "asc", + }, + }) + } + + async findByOpenId(openId: string): Promise { + return ( + (await db.query.integrationTiktokModel.findFirst({ + where: { openId }, + })) ?? null + ) + } + + async connect(input: { + workspaceId: string + ownerId: string + openId: string + username: string + displayName: string + auth: Record + }): Promise<{ + wasCreated: boolean + integration: { id: string } | undefined + }> { + const { workspaceId, ownerId, openId, username, displayName, auth } = input + const integrationId = createId() + + const { wasCreated, integration } = await db.transaction(async (tx) => + connectChannelIntegration({ + tx, + ownerId, + inboxData: { + workspaceId, + name: displayName, + channel: "tiktok", + sourceId: username, + }, + insertIntegration: async (inboxId) => { + const [row] = await tx + .insert(integrationTiktokModel) + .values({ + id: integrationId, + inboxId, + workspaceId, + openId, + name: displayName, + auth, + }) + .onConflictDoUpdate({ + target: [integrationTiktokModel.openId], + set: { + auth, + name: displayName, + tokenRefreshError: null, + }, + }) + .returning({ id: integrationTiktokModel.id }) + + return row + }, + }), + ) + + return { wasCreated, integration } + } + + async disconnect(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, inboxId, ownerId, tx } = input + + const run = async (client: DatabaseClient) => { + await client + .delete(integrationTiktokModel) + .where( + and( + eq(integrationTiktokModel.id, id), + eq(integrationTiktokModel.workspaceId, workspaceId), + ), + ) + await inboxService.disconnect({ + inboxId, + ownerId, + workspaceId, + reason: "manual", + tx: client, + }) + } + + if (tx) { + await run(tx) + return + } + await db.transaction(run) + } } export const tiktokIntegrationService = new TiktokIntegrationService() diff --git a/packages/business/src/integration-webchat/service.ts b/packages/business/src/integration-webchat/service.ts index 836caae938..73019a1970 100644 --- a/packages/business/src/integration-webchat/service.ts +++ b/packages/business/src/integration-webchat/service.ts @@ -1,13 +1,34 @@ import type { DatabaseClient } from "@chatbotx.io/database/client" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { + and, + db, + eq, + findOrFail, + relationsFilterToSQL, +} from "@chatbotx.io/database/client" import { integrationWebchatModel } from "@chatbotx.io/database/schema" import type { IntegrationWebchatModel } from "@chatbotx.io/database/types" +import { parsePagination } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { inboxService } from "../inbox/service" import { assertDeletable } from "../template/installed-resource.service" import { workspaceService } from "../workspace" +export type UpdateWebchatData = Partial<{ + name: string + enable: boolean + authorizedDomains: string[] + conversationStarters: unknown[] + persistentMenus: unknown[] + brandColor: string + hideHeader: boolean + showLogo: boolean + hideMessageInput: boolean + customCss: string | null + welcomeFlowId: string | null +}> + export type CreateWebchatRequest = { name: string auth: Record @@ -88,56 +109,6 @@ 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({ @@ -173,6 +144,133 @@ class IntegrationWebchatService extends BaseService { `disconnected the Webchat channel (#${integrationWebchat.id})`, ) } + + /** + * Optionally provisions a workspace, then reuses `create` (above) inside + * the same transaction to insert the Inbox + IntegrationWebchat row. + */ + async createWithWorkspace(input: { + workspaceId?: string + createdBy: string + workspaceName: string + data: CreateWebchatRequest + }): Promise<{ + workspaceId: string + createdWorkspace: boolean + webchatId: string + }> { + const { createdBy, workspaceName, data } = input + let ownerId = createdBy + + const result = await db.transaction(async (tx) => { + let workspaceId = input.workspaceId + let createdWorkspace = false + + if (workspaceId) { + const workspace = await workspaceService.findOrFail({ + where: { id: workspaceId }, + }) + ownerId = workspace.ownerId + } else { + const newWorkspace = await workspaceService.create({ + tx, + createdBy, + data: { + name: workspaceName, + timezone: "UTC", + ownerId, + }, + }) + workspaceId = newWorkspace.id + createdWorkspace = true + } + + const created = await this.create({ workspaceId, ownerId, data }, tx) + + return { workspaceId, createdWorkspace, webchatId: created.id } + }) + + return result + } + + async findByIdForWorkspaceOrNull(props: { + id: string + workspaceId: string + }): Promise { + return await db.query.integrationWebchatModel.findFirst({ + where: { id: props.id, workspaceId: props.workspaceId }, + }) + } + + async findByIdForWorkspace(props: { + id: string + workspaceId: string + }): Promise { + return await findOrFail({ + table: integrationWebchatModel, + where: { id: props.id, workspaceId: props.workspaceId }, + message: "Webchat integration not found", + }) + } + + async list(input: { + workspaceId: string + page?: number + perPage?: number + }): Promise<{ data: IntegrationWebchatModel[]; pageCount: number }> { + 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 pageCount = pagination?.limit + ? Math.ceil(totalRows / pagination.limit) + : 1 + return { data, pageCount } + } + + async update(input: { + workspaceId: string + id: string + data: UpdateWebchatData + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, data, tx = db } = input + + // `workspaceId` scopes the row, it is never written: assigning it in `set` + // would silently move the webchat to another workspace on a mismatched + // (id, workspaceId) pair. Callers pre-check via `findByIdForWorkspace`, but + // this method accepts a `workspaceId` and must enforce it on its own. + await tx + .update(integrationWebchatModel) + .set({ + ...data, + conversationStarters: data.conversationStarters as never, + persistentMenus: data.persistentMenus as never, + }) + .where( + and( + eq(integrationWebchatModel.id, id), + eq(integrationWebchatModel.workspaceId, workspaceId), + ), + ) + } } export const integrationWebchatService = new IntegrationWebchatService() diff --git a/packages/business/src/integration-zalo/service.ts b/packages/business/src/integration-zalo/service.ts index 2ade897868..7a2e4ec54c 100644 --- a/packages/business/src/integration-zalo/service.ts +++ b/packages/business/src/integration-zalo/service.ts @@ -1,11 +1,5 @@ -import { - and, - type DatabaseClient, - db, - eq, - findOrFail, - inArray, -} from "@chatbotx.io/database/client" +import type { DatabaseClient } from "@chatbotx.io/database/client" +import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" import { channelTypes } from "@chatbotx.io/database/partials" import { integrationZaloModel, @@ -13,86 +7,17 @@ import { } from "@chatbotx.io/database/schema" import type { IntegrationZaloModel } from "@chatbotx.io/database/types" import { BaseService } from "../base.service" +import { channelDuplicatedException } from "../errors" import { connectChannelIntegration } from "../inbox/connect-channel" - -export type ConnectZaloInput = { - tx: DatabaseClient - ownerId: string - workspaceId: string - oaId: string - oaName: string - auth: Record -} +import { inboxService } from "../inbox/service" +import { logger } from "../logger" +import { tagSyncService } from "../tag/sync.service" 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 @@ -181,6 +106,129 @@ class ZaloIntegrationService extends BaseService { where: { oaId: props.oaId }, }) } + + async listByWorkspace( + where: Partial>, + ): Promise { + return await db.query.integrationZaloModel.findMany({ + where, + orderBy: { + createdAt: "asc", + }, + }) + } + + async connect(input: { + workspaceId: string + ownerId: string + oaId: string + name: string + auth: Record + }): Promise<{ integrationId: string | undefined; wasCreated: boolean }> { + const { workspaceId, ownerId, oaId, name, auth } = input + + let connectedIntegrationId: string | undefined + let channelWasCreated = false + + await db.transaction(async (tx) => { + const { wasCreated } = await connectChannelIntegration({ + tx, + ownerId, + inboxData: { + workspaceId, + name, + channel: "zalo", + sourceId: oaId, + }, + insertIntegration: async (inboxId, insertWasCreated) => { + if (!insertWasCreated) { + throw channelDuplicatedException() + } + const [row] = await tx + .insert(integrationZaloModel) + .values({ + inboxId, + workspaceId, + oaId, + auth, + name, + }) + .returning({ id: integrationZaloModel.id }) + connectedIntegrationId = row?.id + }, + }) + channelWasCreated = wasCreated + }) + + await this.invalidateCacheTags(`workspaces:${workspaceId}#zalos`) + + // Import any tags already on the OA into local tags + mappings. The row is + // already committed, so a queue outage must not fail the connect — and must + // not run before the caller's audit record either, or a throw here would + // leave a connected channel with no audit trail. + if (connectedIntegrationId) { + await tagSyncService + .enqueueChannelScan({ + workspaceId, + channelType: channelTypes.enum.zalo, + integrationId: connectedIntegrationId, + }) + .catch((err) => { + logger.warn( + { err, workspaceId, integrationId: connectedIntegrationId }, + "zalo connect: channel tag scan enqueue failed", + ) + }) + } + + return { + integrationId: connectedIntegrationId, + wasCreated: channelWasCreated, + } + } + + async disconnect(input: { + workspaceId: string + id: string + inboxId: string + ownerId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, inboxId, ownerId, tx } = input + + const run = async (client: DatabaseClient) => { + // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId + await client + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.zalo), + eq(tagChannelModel.integrationId, id), + ), + ) + await client + .delete(integrationZaloModel) + .where( + and( + eq(integrationZaloModel.id, id), + eq(integrationZaloModel.workspaceId, workspaceId), + ), + ) + await inboxService.disconnect({ + inboxId, + ownerId, + workspaceId, + reason: "manual", + tx: client, + }) + } + + if (tx) { + await run(tx) + return + } + await db.transaction(run) + } } export const zaloIntegrationService = new ZaloIntegrationService() diff --git a/packages/business/src/integration/service.ts b/packages/business/src/integration/service.ts index 6a0c5ead30..92a72d2f97 100644 --- a/packages/business/src/integration/service.ts +++ b/packages/business/src/integration/service.ts @@ -45,31 +45,6 @@ class IntegrationService extends BaseService { }) } - 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() @@ -213,6 +188,25 @@ class IntegrationService extends BaseService { })), ] } + + /** + * Boolean gate for whether a workspace has any integration whose type is + * in `integrationTypes` (e.g. an AI provider). The caller supplies the + * type list — business does not depend on `@chatbotx.io/ai`. + */ + async hasIntegrationOfTypes(props: { + workspaceId: string + integrationTypes: string[] + }): Promise { + const existing = await db.query.integrationModel.findFirst({ + where: { + integrationType: { in: props.integrationTypes }, + workspaceId: props.workspaceId, + }, + }) + + return !!existing + } } export const integrationService = new IntegrationService() diff --git a/packages/business/src/sequence/service.ts b/packages/business/src/sequence/service.ts index 6840157c32..bfef7f6c46 100644 --- a/packages/business/src/sequence/service.ts +++ b/packages/business/src/sequence/service.ts @@ -1,3 +1,5 @@ +import { sequenceAnalyticsService } from "@chatbotx.io/analytics" +import type { SequenceStepEventType } from "@chatbotx.io/analytics/schemas" import { and, db, @@ -17,6 +19,11 @@ import type { import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { + mapStatsContactRow, + type StatsContactRow, +} from "../contact-inbox/map-stats-contact-row" +import { contactInboxService } from "../contact-inbox/service" import { notFoundException, validationException } from "../errors" import { handleStepCreationImpact, @@ -197,6 +204,98 @@ class SequenceService extends BaseService { }) } + /** + * One page of a sequence step's recipients for a given delivery event, + * with contact display fields attached — shared by callers of the + * "list sequence step contacts" route so the orchestration (analytics + * lookup → contact-inbox fetch → row shape) lives in one place instead of + * being copy-pasted per handler. + * + * Unlike `broadcastService.listContactsPage` there is no up-front + * existence/ownership assertion, because every read below is already + * workspace-scoped in SQL (`sequenceStatsRepository.getContacts` filters on + * `workspaceId`, and `contactInboxService.findManyByIds` requires one). A + * foreign or non-existent `sequenceId` therefore yields an empty page + * rather than another workspace's rows — it just does not 404. + * + * `total` is caller-supplied rather than repository-computed: unlike + * broadcasts, `sequenceStatsRepository.getContacts` has no count query + * today, so trusting the client-reported total here preserves existing + * behaviour. Adding a server-computed count is a real analytics change + * and belongs in its own PR — don't "fix" this without one. + * + * @remarks Behavior change from the pre-refactor per-handler + * implementation: a contact-inbox row with no conversation used to be + * dropped entirely (`if (!conversationId) return []`). This method keeps + * the row and reports `conversationId: ""` instead, matching how + * `broadcastService.listContactsPage` has always handled the same case. + * `data.length` can no longer silently fall short of `total` for this + * reason. The dialog UI already guards on truthiness + * (`stats-contacts-dialog.tsx`), so an empty `conversationId` renders as + * plain text rather than a broken inbox link — but the contact becomes + * selectable/taggable where it previously was not shown at all. + */ + async listStepContactsPage(input: { + workspaceId: string + sequenceId: string + stepId: string + eventType: SequenceStepEventType + total: number + page: number + perPage: number + }): Promise<{ + data: (StatsContactRow & { conversationId: string })[] + total: number + pageCount: number + }> { + const { workspaceId, sequenceId, stepId, eventType, page, perPage } = input + const total = input.total || 0 + const pageCount = Math.ceil(total / perPage) + + const { contactInboxIds, contactEventMap } = + await sequenceAnalyticsService.getContacts({ + workspaceId, + sequenceId, + stepId, + eventType, + page, + perPage, + }) + + if (contactInboxIds.length === 0) { + return { data: [], total, pageCount } + } + + const contactInboxes = await contactInboxService.findManyByIds({ + workspaceId, + ids: contactInboxIds, + }) + const contactMap = new Map(contactInboxes.map((c) => [c.id, c])) + + const data = contactInboxIds + .map((contactInboxId) => { + const row = mapStatsContactRow( + contactInboxId, + contactEventMap.get(contactInboxId), + contactMap.get(contactInboxId), + ) + if (!row) { + return null + } + return { + ...row, + conversationId: + contactMap.get(contactInboxId)?.conversation?.id ?? "", + } + }) + .filter( + (row): row is StatsContactRow & { conversationId: string } => + row !== null, + ) + + return { data, total, pageCount } + } + async findWithSteps(input: { workspaceId: string; id: string }) { const sequence = await sequenceRepository.findWithSteps({ id: input.id, diff --git a/packages/business/src/user/service.ts b/packages/business/src/user/service.ts index 7e0f690969..d5a81f75bc 100644 --- a/packages/business/src/user/service.ts +++ b/packages/business/src/user/service.ts @@ -1,4 +1,4 @@ -import { db, eq } from "@chatbotx.io/database/client" +import { db, eq, inArray } from "@chatbotx.io/database/client" import { userModel } from "@chatbotx.io/database/schema" import type { UserModel } from "@chatbotx.io/database/types" import { BaseService } from "../base.service" @@ -25,6 +25,25 @@ class UserService extends BaseService { } return user } + + /** + * Existing user ids from `userIds`. A Redis live-counter key can outlive + * the User it belonged to (deleting a User cascades its UserQuota row but + * not the Redis key) — callers use this to filter such ghost ids out + * before reconciling, instead of violating the UserQuota → User foreign + * key on every run. + */ + async listExistingIds(userIds: string[]): Promise { + if (userIds.length === 0) { + return [] + } + + const rows = await db + .select({ id: userModel.id }) + .from(userModel) + .where(inArray(userModel.id, userIds)) + return rows.map((row) => row.id) + } } export const userService = new UserService()