From 4d0d24f5b3d36ef8c36fb4d0a7e66ab1534e6e23 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Sun, 6 Sep 2026 12:21:59 +0700 Subject: [PATCH 1/5] refactor(worker): move integration, chat and ai-agent handler data access into business Removes direct db usage from apps/worker/src/{integration,chat,ai-agent, services,lib} per .agents/rules/data-access.md. Hot-path bodies are moved verbatim; every isNull TOCTOU guard, sql increment, untargeted onConflictDoNothing, sql.identifier and withBlockedOwnerGuard call site is preserved. - coexist: claimRunForSync/incrementProgress/findInitState/... on the coexist-sync-run repository, staging repository methods, and a new coexistImportService holding the historical-import transaction - contact/tag/sequence flow steps, inbox labels, templates, message status and received-message activity tracking routed through contactService, tagService, contactSequenceService, conversationService, inboxService and the integration services - sequenceDispatchUtils gains findRunning/markCompleted/markCanceled/ markFailed; new ai-embedding, integration-lookup and import repositories; lib/db.ts keeps its exported names and signatures Deliberate behavior changes (recorded in the PR): stepBlockContact now goes through contactService.block (emits + invalidates), setFlowFlags invalidates the contact cache, process-ai-file enqueues only the ids it inserted, send-flow-direct and update-avatar add workspace scoping, and the inbound/outbound activity conversation updates add workspaceId to their WHERE. --- .../__tests__/bulk-historical-import.test.ts | 534 +++++------------- .../__tests__/coexist-messenger-sync.test.ts | 339 +++++------ .../__tests__/coexist-whatsapp-buffer.test.ts | 91 +-- .../__tests__/get-data-from-json-step.test.ts | 16 +- apps/worker/__tests__/inbox-labels.test.ts | 351 ++++++------ ...ntegration-worker-incoming-message.test.ts | 6 + apps/worker/__tests__/read-receipts.test.ts | 11 +- .../worker/__tests__/received-message.test.ts | 137 ++--- .../__tests__/resolve-workspace-id.test.ts | 10 +- apps/worker/__tests__/send-flow-step.test.ts | 85 +-- .../send-messenger-template-create.test.ts | 75 +-- .../__tests__/send-messenger-template.test.ts | 14 +- .../__tests__/send-whatsapp-template.test.ts | 70 +-- apps/worker/__tests__/sequence-flow.test.ts | 127 ++--- .../handlers/summarize-conversation.ts | 4 +- .../src/chat/handlers/send-flow-step.ts | 86 +-- .../chat/handlers/send-messenger-template.ts | 37 +- .../chat/handlers/send-whatsapp-template.ts | 32 +- .../coexist/bulk-historical-import.ts | 422 +------------- .../handlers/coexist/messenger-sync.ts | 250 +++----- .../handlers/coexist/whatsapp-buffer.ts | 25 +- .../handlers/contact/update-avatar.ts | 20 +- .../handlers/generate-text-agent/index.ts | 4 +- .../inbox_labels/channels/messenger.ts | 6 +- .../handlers/inbox_labels/channels/zalo.ts | 6 +- .../integration/handlers/inbox_labels/sync.ts | 171 ++---- .../integration/handlers/message-status.ts | 10 +- .../handlers/messenger-template-handler.ts | 35 +- .../integration/handlers/received-message.ts | 48 +- apps/worker/src/integration/handlers/ref.ts | 54 -- .../integration/handlers/send-flow-direct.ts | 16 +- .../src/integration/handlers/sequence-flow.ts | 70 +-- .../src/integration/handlers/step-handlers.ts | 121 ++-- .../src/integration/handlers/tool-handler.ts | 14 +- .../handlers/wa-template-handler.ts | 26 +- apps/worker/src/integration/utils/contact.ts | 25 +- apps/worker/src/lib/db.ts | 28 +- apps/worker/src/lib/resolve-workspace-id.ts | 14 +- apps/worker/src/services/integrations.ts | 41 +- .../__tests__/coexist-import-service.test.ts | 317 +++++++++++ .../contact-service-flow-flags.test.ts | 237 ++++++++ ...ersation-service-consume-challenge.test.ts | 9 + .../__tests__/conversation-service.test.ts | 9 + packages/business/src/coexist-import/index.ts | 1 + .../business/src/coexist-import/service.ts | 527 +++++++++++++++++ packages/business/src/coexist/service.ts | 31 + .../business/src/contact-sequence/service.ts | 75 +++ packages/business/src/contact/service.ts | 78 +++ .../__tests__/conversation.service.test.ts | 23 + packages/business/src/conversation/service.ts | 214 +++++++ .../src/enterprise/inbox-team/service.ts | 30 + packages/business/src/flow-version/service.ts | 17 + packages/business/src/flow/service.ts | 30 + packages/business/src/inbox/service.ts | 38 ++ packages/business/src/index.ts | 1 + .../src/integration-messenger/service.ts | 37 +- .../src/integration-whatsapp/service.ts | 22 +- .../business/src/integration-zalo/service.ts | 11 + packages/business/src/tag/service.ts | 200 ++++++- .../business/src/workspace-member/service.ts | 19 + .../coexist-sync-run-repository.test.ts | 122 ++++ .../coexist-sync-run/repository.ts | 134 +++++ .../repositories/contact-inbox/repository.ts | 89 ++- .../src/repositories/contact/repository.ts | 29 + .../database/src/repositories/import/index.ts | 1 + .../src/repositories/import/repository.ts | 26 + packages/database/src/repositories/index.ts | 2 + .../repositories/integration-lookup/index.ts | 1 + .../integration-lookup/repository.ts | 44 ++ .../integration-messenger/repository.ts | 29 + .../whatsapp-coexist-staging/repository.ts | 16 + packages/database/src/types.ts | 2 + .../src/sequence-dispatch.ts | 99 ++++ 73 files changed, 3595 insertions(+), 2356 deletions(-) create mode 100644 packages/business/__tests__/coexist-import-service.test.ts create mode 100644 packages/business/__tests__/contact-service-flow-flags.test.ts create mode 100644 packages/business/src/coexist-import/index.ts create mode 100644 packages/business/src/coexist-import/service.ts create mode 100644 packages/database/src/repositories/import/index.ts create mode 100644 packages/database/src/repositories/import/repository.ts create mode 100644 packages/database/src/repositories/integration-lookup/index.ts create mode 100644 packages/database/src/repositories/integration-lookup/repository.ts diff --git a/apps/worker/__tests__/bulk-historical-import.test.ts b/apps/worker/__tests__/bulk-historical-import.test.ts index a1fce00c5e..f9b6133116 100644 --- a/apps/worker/__tests__/bulk-historical-import.test.ts +++ b/apps/worker/__tests__/bulk-historical-import.test.ts @@ -1,26 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // --------------------------------------------------------------------------- -// Hoist mocks — the bulk pipeline calls many DB primitives we must stub: -// transaction() runs the callback inline against a fake `tx` object that -// exposes select(), insert(), update(), delete(), and execute(). +// Hoist mocks. The whole `bulkImportContacts` transaction (select existing +// ContactInbox rows, resolve/heal Conversations, insert Contact+ContactInbox+ +// Conversation, race recovery, scoped-user-id aliasing) moved VERBATIM into +// `coexistImportService.resolveOrCreateContactLinks` — the worker layer no +// longer touches `db`/`tx` at all for that phase. `bulkImportMessages` still +// inserts via `createMessageRepository().bulkCreate()` (unchanged) but now +// enriches the Contact row via `contactRepository.enrichIfNull` instead of a +// raw `tx.execute(sql...)`. // --------------------------------------------------------------------------- const { - mockTransaction, - mockTxSelect, - mockTxInsert, - mockTxUpdate, - mockTxDelete, - mockTxExecute, - mockDbUpdate, mockEmitContactCreated, mockEmit, - mockCreateId, mockBulkCreate, mockBulkUpdateTracking, mockCreateMessageRepository, - mockWorkspaceFind, + mockResolveOrCreateContactLinks, + mockEnrichIfNull, mockWorkspaceUsageIncrement, mockBulkAdvanceActivityAndAiContextMarker, } = vi.hoisted(() => { @@ -31,20 +29,13 @@ const { bulkCreateAttachments: mockBulkCreateAttachments, }) return { - mockTransaction: vi.fn(), - mockTxSelect: vi.fn(), - mockTxInsert: vi.fn(), - mockTxUpdate: vi.fn(), - mockTxDelete: vi.fn(), - mockTxExecute: vi.fn(), - mockDbUpdate: vi.fn(), mockEmitContactCreated: vi.fn(() => Promise.resolve()), mockEmit: vi.fn(() => Promise.resolve()), - mockCreateId: vi.fn(), mockBulkCreate, mockBulkUpdateTracking: vi.fn().mockResolvedValue(null), mockCreateMessageRepository, - mockWorkspaceFind: vi.fn(), + mockResolveOrCreateContactLinks: vi.fn(), + mockEnrichIfNull: vi.fn().mockResolvedValue(undefined), mockWorkspaceUsageIncrement: vi.fn().mockResolvedValue(undefined), mockBulkAdvanceActivityAndAiContextMarker: vi .fn() @@ -52,71 +43,21 @@ const { } }) -vi.mock("@chatbotx.io/database/client", () => { - const tx = { - select: mockTxSelect, - insert: mockTxInsert, - update: mockTxUpdate, - delete: mockTxDelete, - execute: mockTxExecute, - } - mockTransaction.mockImplementation((cb: (tx: unknown) => unknown) => cb(tx)) - // db.update() is used best-effort outside transactions (lastMessageAt bump). - // Return a chainable stub so it never throws. - mockDbUpdate.mockImplementation(() => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - return chain - }) - return { - db: { - execute: mockTxExecute, - transaction: mockTransaction, - update: mockDbUpdate, - }, - and: vi.fn((...args: unknown[]) => ({ __and: args })), - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), - inArray: vi.fn((col: unknown, vals: unknown) => ({ - __inArray: [col, vals], - })), - or: vi.fn((...args: unknown[]) => ({ __or: args })), - sql: Object.assign( - (strings: TemplateStringsArray, ..._args: unknown[]) => ({ - __sql: strings.raw, - }), - { - raw: (s: string) => s, - join: (chunks: unknown[], _sep?: unknown) => ({ __join: chunks }), - }, - ), - } -}) - -vi.mock("@chatbotx.io/database/schema", () => ({ - attachmentModel: { id: "att_id" }, - contactInboxModel: { - id: "ci_id", - sourceId: "ci_sourceId", - sourceUserId: "ci_sourceUserId", - contactId: "ci_contactId", - inboxId: "ci_inboxId", - }, - contactModel: { id: "c_id" }, - conversationModel: { id: "conv_id", contactId: "conv_contactId" }, - messageModel: { - id: "m_id", - contactInboxId: "m_ci", - sourceId: "m_sid", - $inferInsert: {}, - }, +vi.mock("@chatbotx.io/database/client", () => ({ + describeDatabaseError: vi.fn((err: unknown) => err), })) vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactRepository: { + enrichIfNull: mockEnrichIfNull, + }, })) vi.mock("@chatbotx.io/business", () => ({ + coexistImportService: { + resolveOrCreateContactLinks: mockResolveOrCreateContactLinks, + }, contactInboxService: { bulkUpdateTracking: mockBulkUpdateTracking, }, @@ -124,12 +65,6 @@ vi.mock("@chatbotx.io/business", () => ({ bulkAdvanceActivityAndAiContextMarker: mockBulkAdvanceActivityAndAiContextMarker, }, - messageCleanupService: { - cancelByInboxSource: vi.fn().mockResolvedValue(undefined), - }, - workspaceService: { - find: mockWorkspaceFind, - }, workspaceUsageService: { increment: mockWorkspaceUsageIncrement, }, @@ -139,12 +74,6 @@ vi.mock("@chatbotx.io/event-bus", () => ({ emit: mockEmit })) vi.mock("@chatbotx.io/events", () => ({ emitContactCreated: mockEmitContactCreated, })) -// Partial: `@chatbotx.io/database/partials` calls `zodBigintAsString()` at -// module scope, so replacing the whole module breaks the import chain. -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: mockCreateId } -}) // --------------------------------------------------------------------------- // Import after mocks @@ -152,93 +81,6 @@ vi.mock("@chatbotx.io/utils", async (importOriginal) => { import { bulkImportHistorical } from "../src/integration/handlers/coexist/bulk-historical-import" -// --------------------------------------------------------------------------- -// Helpers — chain builders mirroring Drizzle's fluent API -// --------------------------------------------------------------------------- - -type SelectStubConfig = { - /** Rows returned by .where() for SELECTs without .limit(), or by .limit() otherwise. */ - rows?: unknown[] - /** Set to true when production code chains .limit(n) after .where(). */ - hasLimit?: boolean -} - -/** Stub a single tx.select(...).from(...).where(...)[.limit()] chain returning `rows`. */ -const enqueueSelect = (config: SelectStubConfig = {}) => { - const chain = { - from: vi.fn(), - where: vi.fn(), - limit: vi.fn(), - } - chain.from.mockReturnValue(chain) - if (config.hasLimit) { - // .where() returns the chain for further chaining; .limit() resolves - chain.where.mockReturnValue(chain) - chain.limit.mockResolvedValue(config.rows ?? []) - } else { - chain.where.mockResolvedValue(config.rows ?? []) - } - mockTxSelect.mockReturnValueOnce(chain) - return chain -} - -type InsertStubConfig = { - returningRows?: unknown[] -} - -/** - * Stub tx.insert(...).values(...) which may be terminal, or chain - * .onConflictDoNothing().returning() / .onConflictDoUpdate() / .returning(). - */ -const enqueueInsert = (config: InsertStubConfig = {}) => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn(), - onConflictDoUpdate: vi.fn(), - returning: vi.fn(), - } - chain.values.mockReturnValue(chain) - chain.onConflictDoNothing.mockReturnValue(chain) - chain.onConflictDoUpdate.mockResolvedValue(undefined) - chain.returning.mockResolvedValue(config.returningRows ?? []) - mockTxInsert.mockReturnValueOnce(chain) - return chain -} - -/** - * Stub tx.insert(...).values(...).onConflictDoNothing(...) where the chain is - * AWAITED directly without .returning() (e.g. the Conversation insert). - */ -const enqueueInsertNoReturning = () => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn(), - returning: vi.fn(), - } - chain.values.mockReturnValue(chain) - chain.onConflictDoNothing.mockResolvedValue(undefined) - chain.returning.mockResolvedValue([]) - mockTxInsert.mockReturnValueOnce(chain) - return chain -} - -/** Stub tx.update(...).set(...).where(...) — awaitable at .where(). */ -const _enqueueUpdate = () => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - mockTxUpdate.mockReturnValueOnce(chain) - return chain -} - -/** Stub tx.delete(...).where(...). */ -const _enqueueDelete = () => { - const chain = { where: vi.fn() } - chain.where.mockResolvedValue(undefined) - mockTxDelete.mockReturnValueOnce(chain) - return chain -} - const inbox = { id: "inbox-1", workspaceId: "ws-1", @@ -266,13 +108,14 @@ const msg = (sourceId: string, overrides: Record = {}) => ({ }) // --------------------------------------------------------------------------- -// Helpers — stub a "new contacts" happy path inside bulkImportContacts tx. -// Sequence (when trulyNew > 0, no race): -// 1. SELECT existing ContactInbox rows → enqueueSelect({ rows: [] }) -// 2. INSERT Contact (terminal, no .returning()) → enqueueInsert() -// 3. INSERT ContactInbox .returning() → enqueueInsert({ returningRows }) -// 4. INSERT Conversation .onConflictDoNothing() → enqueueInsertNoReturning() -// 5. SELECT conversations for new contacts → enqueueSelect({ rows }) +// Helpers — wire `coexistImportService.resolveOrCreateContactLinks`, which now +// owns the whole contact-resolution transaction (select existing rows, heal +// orphan conversations, insert Contact/ContactInbox/Conversation, race +// recovery, scoped-user-id aliasing) VERBATIM — see +// packages/business/src/coexist-import/service.ts. The worker-layer test only +// asserts `bulkImportContacts`/`bulkImportHistorical` consume this result +// correctly; the transaction internals are covered at the business-service +// layer, not here. // --------------------------------------------------------------------------- type NewContactStub = { @@ -282,27 +125,58 @@ type NewContactStub = { conversationId: string } -const stubNewContactsTransaction = (contacts: NewContactStub[]) => { - // 1. SELECT existing ContactInbox → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: contacts.map((c) => ({ id: c.contactId })) }) - // 3. INSERT ContactInbox .returning() - enqueueInsert({ - returningRows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, +/** "New contacts" happy path — every entry is newly created. */ +const stubNewContactsResolution = (contacts: NewContactStub[]) => { + mockResolveOrCreateContactLinks.mockResolvedValueOnce({ + importedContacts: contacts.length, + contactInboxIds: new Map( + contacts.map((c) => [ + c.sourceId, + { + contactInboxId: c.contactInboxId, + contactId: c.contactId, + conversationId: c.conversationId, + }, + ]), + ), + newContactCreatedEvents: contacts.map((c) => ({ + workspaceId, contactId: c.contactId, + contactInboxId: c.contactInboxId, + sourceId: c.sourceId, + firstName: "Bob", + phoneNumber: undefined, + email: "bob@example.com", + channel: inbox.channel, + source: "inboundMessage", + createdAt: new Date(), })), }) - // 4. INSERT Conversation .onConflictDoNothing() - enqueueInsertNoReturning() - // 5. SELECT conversations for new contacts - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), +} + +type ExistingContactStub = { + sourceId: string + contactId: string + contactInboxId: string + conversationId: string +} + +/** "Already exists" path — every entry resolves to a pre-existing row, no new + * contact created and no `newContactCreatedEvents` emitted. */ +const stubExistingContactsResolution = (contacts: ExistingContactStub[]) => { + mockResolveOrCreateContactLinks.mockResolvedValueOnce({ + importedContacts: 0, + contactInboxIds: new Map( + contacts.map((c) => [ + c.sourceId, + { + contactInboxId: c.contactInboxId, + contactId: c.contactId, + conversationId: c.conversationId, + }, + ]), + ), + newContactCreatedEvents: [], }) } @@ -313,24 +187,6 @@ const stubNewContactsTransaction = (contacts: NewContactStub[]) => { describe("bulkImportHistorical", () => { beforeEach(() => { vi.clearAllMocks() - let idCounter = 0 - mockCreateId.mockImplementation(() => `id-${++idCounter}`) - // Re-wire transaction default after clearAllMocks resets it. - const tx = { - select: mockTxSelect, - insert: mockTxInsert, - update: mockTxUpdate, - delete: mockTxDelete, - execute: mockTxExecute, - } - mockTransaction.mockImplementation((cb: (tx: unknown) => unknown) => cb(tx)) - // Re-wire db.update default after clearAllMocks. - mockDbUpdate.mockImplementation(() => { - const chain = { set: vi.fn(), where: vi.fn() } - chain.set.mockReturnValue(chain) - chain.where.mockResolvedValue(undefined) - return chain - }) // Re-wire repository mock after clearAllMocks. mockBulkCreate.mockResolvedValue([]) mockBulkUpdateTracking.mockResolvedValue(null) @@ -339,14 +195,11 @@ describe("bulkImportHistorical", () => { bulkCreate: mockBulkCreate, bulkCreateAttachments: vi.fn().mockResolvedValue([]), }) - mockWorkspaceFind.mockResolvedValue({ - id: "workspace-1", - ownerId: "owner-1", - }) + mockEnrichIfNull.mockResolvedValue(undefined) mockWorkspaceUsageIncrement.mockResolvedValue(undefined) }) - it("empty batch returns zero counts without opening a transaction", async () => { + it("empty batch returns zero counts without calling resolveOrCreateContactLinks", async () => { const result = await bulkImportHistorical({ inbox, workspaceId, @@ -365,11 +218,11 @@ describe("bulkImportHistorical", () => { insertedAttachmentIds: [], failureReason: undefined, }) - expect(mockTransaction).not.toHaveBeenCalled() + expect(mockResolveOrCreateContactLinks).not.toHaveBeenCalled() }) it("inserts new contact + messages when no existing ContactInbox matches", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -406,7 +259,7 @@ describe("bulkImportHistorical", () => { }) it("tracks workspace usage for newly-imported coexist contacts without consuming quota", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -424,7 +277,6 @@ describe("bulkImportHistorical", () => { batch: [{ contact: contact("src-1"), messages: [msg("m-src-1")] }], }) - expect(mockWorkspaceFind).not.toHaveBeenCalled() expect(mockWorkspaceUsageIncrement).toHaveBeenCalledWith( workspaceId, "contacts", @@ -433,15 +285,17 @@ describe("bulkImportHistorical", () => { }) it("does not touch the contacts quota when no new contact is imported", async () => { - // All contacts already exist — bulkImportContacts resolves them without - // any new insert, so importedContacts stays 0 (mirrors the idempotent - // re-run scenario below). - enqueueSelect({ - rows: [{ id: "ci-existing", sourceId: "src-1", contactId: "c-existing" }], - }) - enqueueSelect({ - rows: [{ id: "conv-existing", contactId: "c-existing" }], - }) + // All contacts already exist — resolveOrCreateContactLinks resolves them + // without any new insert, so importedContacts stays 0 (mirrors the + // idempotent re-run scenario below). + stubExistingContactsResolution([ + { + sourceId: "src-1", + contactId: "c-existing", + contactInboxId: "ci-existing", + conversationId: "conv-existing", + }, + ]) mockBulkCreate.mockResolvedValueOnce([]) const result = await bulkImportHistorical({ @@ -453,14 +307,13 @@ describe("bulkImportHistorical", () => { }) expect(result.importedContacts).toBe(0) - expect(mockWorkspaceFind).not.toHaveBeenCalled() expect(mockWorkspaceUsageIncrement).not.toHaveBeenCalled() }) it("flushes contact-inbox activity in one bulk service call", async () => { const firstMessageAt = new Date("2026-07-01T01:00:00.000Z") const secondMessageAt = new Date("2026-07-02T02:00:00.000Z") - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -517,7 +370,7 @@ describe("bulkImportHistorical", () => { }) it("advances the AI marker by default (aiReadsSyncedHistory: false) so the AI ignores synced history", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -559,7 +412,7 @@ describe("bulkImportHistorical", () => { }) it("leaves the marker untouched (null) for every row when aiReadsSyncedHistory is true, so the AI reads synced history", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "contact-1", @@ -599,7 +452,7 @@ describe("bulkImportHistorical", () => { }) it("counts duplicates as skippedMessages when message INSERT returns fewer rows than input", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-1", contactId: "id-1", @@ -628,14 +481,16 @@ describe("bulkImportHistorical", () => { }) it("uses existing ContactInbox row for already-known sourceId (idempotent re-run)", async () => { - // existing row present - enqueueSelect({ - rows: [{ id: "ci-existing", sourceId: "src-1", contactId: "c-existing" }], - }) - // conversations lookup for existing contact ids - enqueueSelect({ - rows: [{ id: "conv-existing", contactId: "c-existing" }], - }) + // existing row present — resolveOrCreateContactLinks resolves it without + // any new insert. + stubExistingContactsResolution([ + { + sourceId: "src-1", + contactId: "c-existing", + contactInboxId: "ci-existing", + conversationId: "conv-existing", + }, + ]) // No new contacts → skips cap check, contact insert, etc. // Goes straight to repository.bulkCreate() for messages. mockBulkCreate.mockResolvedValueOnce([]) @@ -658,7 +513,7 @@ describe("bulkImportHistorical", () => { }) it("dedups batch entries that share the same sourceId (merges messages)", async () => { - stubNewContactsTransaction([ + stubNewContactsResolution([ { sourceId: "src-shared", contactId: "id-1", @@ -692,13 +547,16 @@ describe("bulkImportHistorical", () => { // ------------------------------------------------------------------------- it("H7: racedSourceIds lookup produces correct results with many contacts (Set semantics)", async () => { - // Build a scenario where ALL inserted ContactInbox rows "lose the race" - // (the production code considers sourceIds not in insertedSourceIds as - // raced). We do this by making insertedInboxes return an EMPTY array for - // ContactInbox INSERT — so every sourceId is in racedSourceIds — then - // stub the winner re-SELECT to return the real rows. + // This exercised the O(n²)→O(n) raced-contact-resolution internals of + // `bulkImportContacts`'s transaction, which moved VERBATIM into + // `coexistImportService.resolveOrCreateContactLinks` + // (packages/business/src/coexist-import/service.ts) — the race-recovery + // logic itself is covered there, not at this worker-layer boundary. Here + // we only assert the worker correctly consumes a large resolved-links map + // (e.g. from an all-raced resolution) end to end into per-contact message + // imports. // - // With N = 50 contacts this exercises the O(n) path without being slow. + // With N = 50 contacts this exercises the large-batch path without being slow. const N = 50 const contacts = Array.from({ length: N }, (_, i) => ({ sourceId: `src-${i}`, @@ -707,30 +565,8 @@ describe("bulkImportHistorical", () => { conversationId: `conv-${i}`, })) - // 1. SELECT existing ContactInbox → none (all are new) - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: contacts.map((c) => ({ id: c.contactId })) }) - // 3. INSERT ContactInbox — returns EMPTY → ALL sourceIds go to racedSourceIds - enqueueInsert({ returningRows: [] }) - // 4. Race-winner re-SELECT returns all contacts as winners - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, - contactId: c.contactId, - })), - }) - // 5. DELETE orphan contacts (racedSourceIds.length > 0) - _enqueueDelete() - // 6. INSERT Conversation — skipped because all raced (conversationsToInsert = []) - // 7. SELECT conversations for accepted contacts (via inArray on acceptedContactIds) - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), - }) + // All raced → resolved via winner re-SELECT internally, trulyNew = 0. + stubExistingContactsResolution(contacts) // Each contact's bulkImportMessages call goes through repository.bulkCreate() for (let i = 0; i < N; i++) { @@ -768,20 +604,18 @@ describe("bulkImportHistorical", () => { // A username-adopter thread keyed by its BSUID, whose BSUID already // belongs to a phone-keyed row in this inbox. Inserting would violate // the partial unique index (inboxId, sourceUserId) and abort the batch; - // the entry must resolve to the existing row up front. - // 1. SELECT existing (by sourceId OR sourceUserId) → phone-keyed row - enqueueSelect({ - rows: [ - { - id: "ci-old", - sourceId: "phone-1", - sourceUserId: "user.abc", - contactId: "c-old", - }, - ], - }) - // 2. SELECT conversations for existing contacts - enqueueSelect({ rows: [{ id: "conv-old", contactId: "c-old" }] }) + // the entry must resolve to the existing row up front. This resolution + // itself is now owned by `coexistImportService.resolveOrCreateContactLinks` + // — the worker layer only consumes the resolved link, keyed by the + // entry's own sourceUserId-based import key ("user.abc"), same as before. + stubExistingContactsResolution([ + { + sourceId: "user.abc", + contactId: "c-old", + contactInboxId: "ci-old", + conversationId: "conv-old", + }, + ]) mockBulkCreate.mockResolvedValueOnce([{ id: "m-1", sourceId: "m-src-1" }]) const result = await bulkImportHistorical({ @@ -799,83 +633,22 @@ describe("bulkImportHistorical", () => { expect(result.importedContacts).toBe(0) expect(result.importedMessages).toBe(1) expect(result.contactInboxIds.get("user.abc")).toBe("ci-old") - // No Contact/ContactInbox insert was attempted — the conflict never fires. - expect(mockTxInsert).not.toHaveBeenCalled() + // No new-contact insert was attempted — resolveOrCreateContactLinks + // reports zero imported contacts (the conflict never fires). + expect(mockResolveOrCreateContactLinks).toHaveBeenCalledOnce() }) - it("ContactInbox insert uses targetless onConflictDoNothing (covers both identity indexes)", async () => { - // 1. SELECT existing → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: [{ id: "id-1" }] }) - // 3. INSERT ContactInbox .returning() — the chain under test - const contactInboxInsert = enqueueInsert({ - returningRows: [{ id: "ci-1", sourceId: "src-1", contactId: "id-1" }], - }) - // 4. INSERT Conversation - enqueueInsertNoReturning() - // 5. SELECT conversations - enqueueSelect({ rows: [{ id: "conv-1", contactId: "id-1" }] }) - - await bulkImportHistorical({ - inbox, - workspaceId, - runId: "12345", - batch: [{ contact: contact("src-1"), messages: [] }], - }) - - // A target on (inboxId, sourceId) would let a conflict on the partial - // (inboxId, sourceUserId) index abort the whole batch — pin targetless. - expect(contactInboxInsert.onConflictDoNothing).toHaveBeenCalledWith() - }) - - it("raced scoped-id entry resolves through the winner row and aliases the import key", async () => { - // Concurrent import claimed the BSUID between the resolution SELECT and - // the insert: the insert returns no row, the winner is found by scoped - // user id under a DIFFERENT sourceId, and the entry's own import key - // must still map to the winner's link. - // 1. SELECT existing → none - enqueueSelect({ rows: [] }) - // 2. INSERT Contact (terminal) - enqueueInsert({ returningRows: [{ id: "id-1" }] }) - // 3. INSERT ContactInbox → EMPTY: the row lost the race - enqueueInsert({ returningRows: [] }) - // 4. Winner re-SELECT by sourceId → none (loser conflicted on sourceUserId) - enqueueSelect({ rows: [] }) - // 5. Winner re-SELECT by scoped user id → phone-keyed winner row - enqueueSelect({ - rows: [ - { - id: "ci-w", - sourceId: "phone-9", - sourceUserId: "user.abc", - contactId: "c-w", - }, - ], - }) - // 6. DELETE orphan pre-created contacts - _enqueueDelete() - // 7. Conversation insert skipped (all raced); SELECT conversations - enqueueSelect({ rows: [{ id: "conv-w", contactId: "c-w" }] }) - mockBulkCreate.mockResolvedValueOnce([{ id: "m-1", sourceId: "m-src-1" }]) - - const result = await bulkImportHistorical({ - inbox, - workspaceId, - runId: "12345", - batch: [ - { - contact: contact("user.abc", { sourceUserId: "user.abc" }), - messages: [msg("m-src-1")], - }, - ], - }) - - expect(result.importedContacts).toBe(0) - expect(result.importedMessages).toBe(1) - // The entry's import key aliases to the winner's contact-inbox row. - expect(result.contactInboxIds.get("user.abc")).toBe("ci-w") - }) + // NOTE: the targetless-onConflictDoNothing pin (a target on + // (inboxId, sourceId) would let a conflict on the partial + // (inboxId, sourceUserId) index abort the whole batch) and the + // raced-scoped-id winner-aliasing behavior both live entirely inside the + // `resolveOrCreateContactLinks` transaction now — see + // packages/business/src/coexist-import/service.ts, which is the correct + // place to assert those DB-shape invariants going forward. At the worker + // boundary we only assert the resolved link is consumed and threaded + // through message import + emitted events correctly, covered by the + // "resolves an entry by scoped user id" test above and + // "inserts new contact + messages" below. // ------------------------------------------------------------------------- // H4 — bulkImportHistorical parallelizes per-contact bulkImportMessages @@ -911,21 +684,8 @@ describe("bulkImportHistorical", () => { }, ] - // bulkImportContacts: all existing → no cap check needed - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.contactInboxId, - sourceId: c.sourceId, - contactId: c.contactId, - })), - }) - // Conversation lookup for existing contacts - enqueueSelect({ - rows: contacts.map((c) => ({ - id: c.conversationId, - contactId: c.contactId, - })), - }) + // bulkImportContacts: all existing → no new-contact insert needed + stubExistingContactsResolution(contacts) // Track concurrency of repository.bulkCreate calls for the message-import phase. // Each bulkImportMessages call invokes bulkCreate once (after messages are built). diff --git a/apps/worker/__tests__/coexist-messenger-sync.test.ts b/apps/worker/__tests__/coexist-messenger-sync.test.ts index 9c71bc84e1..96bd7a3b4b 100644 --- a/apps/worker/__tests__/coexist-messenger-sync.test.ts +++ b/apps/worker/__tests__/coexist-messenger-sync.test.ts @@ -5,9 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // --------------------------------------------------------------------------- const { - mockFindFirstMessenger, - mockFindFirstWorkspace, - mockFindFirstCoexistRun, + mockFindByIdMessenger, + mockFindByIdWorkspace, + mockFindWorkspace, + mockFindLastSyncedAt, + mockFindInitState, + mockFindResumeCeiling, + mockClaimRunForSync, + mockUpdateProgress, + mockIncrementProgress, + mockFindTerminalCounters, + mockMarkFailed, + mockListContactLinksBySourceIds, mockFindOrFail, mockListConversations, mockListMessages, @@ -15,15 +24,22 @@ const { mockBulkImportMessages, mockBulkImportContacts, mockCreateIdFactory, - mockSelect, - mockUpdate, mockQueueAdd, mockConcurrencyForUsage, mockApplyCoexistActivityUpdates, } = vi.hoisted(() => ({ - mockFindFirstMessenger: vi.fn(), - mockFindFirstWorkspace: vi.fn(), - mockFindFirstCoexistRun: vi.fn(), + mockFindByIdMessenger: vi.fn(), + mockFindByIdWorkspace: vi.fn(), + mockFindWorkspace: vi.fn(), + mockFindLastSyncedAt: vi.fn(), + mockFindInitState: vi.fn(), + mockFindResumeCeiling: vi.fn(), + mockClaimRunForSync: vi.fn(), + mockUpdateProgress: vi.fn(), + mockIncrementProgress: vi.fn(), + mockFindTerminalCounters: vi.fn(), + mockMarkFailed: vi.fn(), + mockListContactLinksBySourceIds: vi.fn(), mockFindOrFail: vi.fn(), mockListConversations: vi.fn(), mockListMessages: vi.fn(), @@ -31,8 +47,6 @@ const { mockBulkImportMessages: vi.fn(), mockBulkImportContacts: vi.fn(), mockCreateIdFactory: vi.fn(), - mockSelect: vi.fn(), - mockUpdate: vi.fn(), mockQueueAdd: vi.fn(), mockConcurrencyForUsage: vi.fn(() => 5), mockApplyCoexistActivityUpdates: vi.fn().mockResolvedValue(undefined), @@ -42,34 +56,42 @@ const { // Mocks // --------------------------------------------------------------------------- +// `findOrFail` is still imported from `@chatbotx.io/database/client` directly +// by messenger-sync.ts for the Inbox lookup — keep this mock minimal (plain +// stub, no schema importOriginal) per the hard rule against opening a real DB +// connection. vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: mockUpdate, - select: mockSelect, - query: { - integrationMessengerModel: { findFirst: mockFindFirstMessenger }, - integrationWhatsappModel: { findFirst: vi.fn() }, - workspaceModel: { findFirst: mockFindFirstWorkspace }, - coexistSyncRunModel: { findFirst: mockFindFirstCoexistRun }, - }, - }, - and: vi.fn(), - eq: vi.fn(), - inArray: vi.fn(), - isNull: vi.fn(), - lt: vi.fn(), - ne: vi.fn(), - or: vi.fn(), - sql: Object.assign( - (strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings, - values, - }), - { raw: (s: string) => s }, - ), findOrFail: mockFindOrFail, })) +vi.mock("@chatbotx.io/business", () => ({ + extractContactInfo: vi.fn(() => ({})), + messengerIntegrationService: { + findById: mockFindByIdMessenger, + }, + workspaceService: { + findById: mockFindByIdWorkspace, + find: mockFindWorkspace, + }, + coexistService: { + findLastSyncedAt: mockFindLastSyncedAt, + findInitState: mockFindInitState, + findResumeCeiling: mockFindResumeCeiling, + claimRunForSync: mockClaimRunForSync, + updateProgress: mockUpdateProgress, + incrementProgress: mockIncrementProgress, + findTerminalCounters: mockFindTerminalCounters, + markFailed: mockMarkFailed, + }, + coexistImportService: { + listContactLinksBySourceIds: mockListContactLinksBySourceIds, + }, +})) + +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("@chatbotx.io/worker-config", () => ({ IntegrationJobAction: { coexistWhatsappBuffer: "coexistWhatsappBuffer", @@ -85,33 +107,7 @@ vi.mock("@chatbotx.io/worker-config", () => ({ })) vi.mock("@chatbotx.io/database/schema", () => ({ - whatsappCoexistStagingModel: {}, - integrationWhatsappModel: {}, - integrationMessengerModel: {}, inboxModel: {}, - contactInboxModel: { - id: "id", - sourceId: "sourceId", - contactId: "contactId", - inboxId: "inboxId", - }, - conversationModel: { id: "id", contactId: "contactId" }, - coexistSyncRunModel: { - id: "id", - lastSyncedAt: "lastSyncedAt", - attempts: "attempts", - importedContactCount: "importedContactCount", - importedMessageCount: "importedMessageCount", - skippedCount: "skippedCount", - failedCount: "failedCount", - currentScan: "currentScan", - currentError: "currentError", - messengerSyncPhase: "messengerSyncPhase", - lastHeartbeatAt: "lastHeartbeatAt", - currentStep: "currentStep", - startedAt: "startedAt", - status: "status", - }, })) vi.mock("@chatbotx.io/integration-messenger/apis/sync", () => ({ @@ -145,12 +141,6 @@ vi.mock("../src/integration/handlers/coexist/bulk-historical-import", () => ({ }, })) -// Break the pino import chain that comes through @chatbotx.io/business → -// @chatbotx.io/redis → @chatbotx.io/logger → pino. -vi.mock("@chatbotx.io/business", () => ({ - extractContactInfo: vi.fn(() => ({})), -})) - // --------------------------------------------------------------------------- // Import handler after mocks // --------------------------------------------------------------------------- @@ -257,12 +247,12 @@ const defaultContactLink = { // --------------------------------------------------------------------------- /** - * Chainable select stub. The chain: - * - `.from().leftJoin().where()` → resolves to `contactLinks` (JOIN path) - * - `.from().where().limit(1)` → resolves to `[runRow]` (run row path) - * - * Both paths reuse the same chain so `mockSelect.mockReturnValue(chain)` works - * for all `db.select()` callsites in a single test. + * Wires the two service calls the source uses in place of the old raw + * `db.select()` JOIN/limit chain: + * - `coexistService.findLastSyncedAt` → the run-row-existence + watermark + * check that each phase does at its start (`{ lastSyncedAt } | null`). + * - `coexistImportService.listContactLinksBySourceIds` → the + * ContactInbox+Conversation JOIN, keyed by sourceId. */ const wireSelectChain = ( runRow: ReturnType | null, @@ -273,52 +263,22 @@ const wireSelectChain = ( conversationId: string }> = [], ) => { - const limitFn = vi.fn().mockResolvedValue(runRow ? [runRow] : []) - // A thenable that resolves to contactLinks AND has .limit() for run-row queries. - const whereResult = Object.assign(Promise.resolve(contactLinks), { - limit: limitFn, - }) - - const chain = { - from: vi.fn(), - leftJoin: vi.fn(), - where: vi.fn(), - limit: vi.fn().mockResolvedValue(runRow ? [runRow] : []), - } - chain.from.mockReturnValue(chain) - chain.leftJoin.mockReturnValue(chain) - chain.where.mockReturnValue(whereResult) - mockSelect.mockReturnValue(chain) - return chain + mockFindLastSyncedAt.mockResolvedValue( + runRow ? { lastSyncedAt: runRow.lastSyncedAt } : null, + ) + mockListContactLinksBySourceIds.mockResolvedValue(contactLinks) } /** - * Reusable update chain — every db.update() returns a fresh chain. Supports - * both the "fire-and-forget" pattern (`await db.update().set().where()`) AND - * the optimistic-claim pattern (`await db.update().set().where().returning()`). - * `.where()` returns a real Promise (resolves to undefined for the - * fire-and-forget path) with `.returning()` attached for the claim path — - * using a real Promise avoids the `noThenProperty` lint while staying - * awaitable. The default claim result is `[{ id: runId }]` so the handler - * treats the run as successfully claimed; tests that need "already claimed" - * pass `wireUpdateChain([])`. + * Wires `coexistService.claimRunForSync` — the optimistic claim that replaces + * the old raw `db.update().set().where().returning()` chain. Default claim + * result is the run row (handler treats the run as successfully claimed); + * tests that need "already claimed" pass `wireUpdateChain(null)`. */ const wireUpdateChain = ( - claimResult: Array<{ id: string }> = [{ id: runId }], + claimResult: { id: string } | null = { id: runId }, ) => { - mockUpdate.mockImplementation(() => { - const chain = { - set: vi.fn(), - where: vi.fn(), - } - chain.set.mockReturnValue(chain) - chain.where.mockImplementation(() => - Object.assign(Promise.resolve(undefined), { - returning: vi.fn().mockResolvedValue(claimResult), - }), - ) - return chain - }) + mockClaimRunForSync.mockResolvedValue(claimResult) } // --------------------------------------------------------------------------- @@ -342,13 +302,27 @@ describe("coexistMessengerSync", () => { mockBulkImportContacts.mockResolvedValue(emptyBulkContactsResult()) mockCreateIdFactory.mockReturnValue(() => "id-factory-result") mockQueueAdd.mockResolvedValue(undefined) - mockFindFirstWorkspace.mockResolvedValue({ targetCountry: "VN" }) - // Default: first run for this integration (no prior CoexistSyncRun). - mockFindFirstCoexistRun.mockResolvedValue(null) + mockFindWorkspace.mockResolvedValue({ targetCountry: "VN" }) + mockFindInitState.mockResolvedValue({ + attempts: 0, + currentError: null, + messengerSyncPhase: "messages", + }) + mockUpdateProgress.mockResolvedValue(undefined) + mockIncrementProgress.mockResolvedValue(undefined) + mockMarkFailed.mockResolvedValue(undefined) + mockFindTerminalCounters.mockResolvedValue({ + importedMessageCount: 0, + skippedCount: 0, + failedCount: 0, + }) + // Default: no ceiling — first run for this integration (no prior + // succeeded/partial CoexistSyncRun). + mockFindResumeCeiling.mockResolvedValue(null) }) it("is a no-op when integration is not found", async () => { - mockFindFirstMessenger.mockResolvedValue(null) + mockFindByIdMessenger.mockResolvedValue(null) await coexistMessengerSync({ runId, integrationId, workspaceId }) @@ -357,7 +331,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when workspaceId mismatches the row", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, workspaceId: "other-ws", }) @@ -369,7 +343,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when coexistEnabled === false", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, coexistEnabled: false, }) @@ -381,7 +355,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when access token is missing", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, auth: { tokens: {}, metadata: {} }, }) @@ -392,7 +366,7 @@ describe("coexistMessengerSync", () => { }) it("is a no-op when CoexistSyncRun row is gone", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) wireSelectChain(null) @@ -403,7 +377,7 @@ describe("coexistMessengerSync", () => { }) it("fetches one page of conversations and invokes bulkImportMessages with assembled messages", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -444,7 +418,7 @@ describe("coexistMessengerSync", () => { }) it("advances the AI marker by default (coexistAiReadsSyncedHistory off), carrying the newest message id", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -478,7 +452,7 @@ describe("coexistMessengerSync", () => { }) it("passes a null aiMarkerMessageId when coexistAiReadsSyncedHistory is on (AI reads synced history)", async () => { - mockFindFirstMessenger.mockResolvedValue({ + mockFindByIdMessenger.mockResolvedValue({ ...fakeIntegration, coexistAiReadsSyncedHistory: true, }) @@ -515,7 +489,7 @@ describe("coexistMessengerSync", () => { }) it("paginates messages within a conversation — flushes bulkImportMessages per message page", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -554,7 +528,7 @@ describe("coexistMessengerSync", () => { }) it("skips conversations that contain only the Page PSID", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // No contact links since there are no valid participants wireSelectChain(defaultRunRow(), []) @@ -577,7 +551,7 @@ describe("coexistMessengerSync", () => { }) it("never uses the Page PSID as a contact sourceId", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const customerId = "user-customer-789" @@ -605,7 +579,7 @@ describe("coexistMessengerSync", () => { }) it("skips messages without a text body", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -637,7 +611,7 @@ describe("coexistMessengerSync", () => { }) it("does not synthesize system time when a Messenger message has no API created_time", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ @@ -669,7 +643,7 @@ describe("coexistMessengerSync", () => { }) it("persists lastSyncedAt watermark (oldest CONVERSATION.updated_time processed) after the page", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Two convs with distinct updated_time. Watermark must track the oldest @@ -712,31 +686,28 @@ describe("coexistMessengerSync", () => { await coexistMessengerSync({ runId, integrationId, workspaceId }) - const allSetCalls = mockUpdate.mock.results - .flatMap((r) => { - const value = r.value as { set?: ReturnType } | undefined - return value?.set?.mock.calls ?? [] - }) - .map((args) => args[0] as Record) - - const watermarkCall = allSetCalls.find( - (payload) => - payload && - "lastSyncedAt" in payload && - payload.lastSyncedAt instanceof Date, - ) + // The per-page update carries the watermark via + // `coexistService.incrementProgress({ fields: { lastSyncedAt, ... } })`. + const watermarkCall = mockIncrementProgress.mock.calls + .map((args) => args[0] as { fields?: Record }) + .find( + (payload) => + payload.fields && + "lastSyncedAt" in payload.fields && + payload.fields.lastSyncedAt instanceof Date, + ) expect(watermarkCall).toBeDefined() - expect((watermarkCall?.lastSyncedAt as Date).toISOString()).toBe( + expect((watermarkCall?.fields?.lastSyncedAt as Date).toISOString()).toBe( olderConvTs, ) }) it("aborts when optimistic claim returns empty (run already owned by another worker)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) - // Override the default wire-up so the claim UPDATE returns [] — handler + // Override the default wire-up so the claim returns null — handler // must log + return without calling listConversations. - wireUpdateChain([]) + wireUpdateChain(null) mockListConversations.mockResolvedValueOnce({ data: [makeConversation("conv-never-fetched", "user-1")], @@ -750,7 +721,7 @@ describe("coexistMessengerSync", () => { }) it("skips conversations whose updated_time is newer than the within-run frontier", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Frontier: anything strictly newer than this was processed in a prior @@ -793,15 +764,14 @@ describe("coexistMessengerSync", () => { }) it("stops the walk when prior run succeeded — ceiling = priorRun.startedAt", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const priorStartedAt = new Date(Date.now() - 12 * 60 * 60 * 1000) - mockFindFirstCoexistRun.mockResolvedValue({ - startedAt: priorStartedAt, - lastSyncedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - status: "succeeded", - }) + // coexistService.findResumeCeiling already derives the ceiling from the + // prior run's status (succeeded → startedAt) — the handler just consumes + // the resolved Date. + mockFindResumeCeiling.mockResolvedValue(priorStartedAt) const belowCeilingTs = new Date( priorStartedAt.getTime() - 60 * 1000, @@ -825,15 +795,14 @@ describe("coexistMessengerSync", () => { }) it("after prior partial — ceiling = priorRun.lastSyncedAt (boundary the prior attempt reached)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) const priorLastSyncedAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - mockFindFirstCoexistRun.mockResolvedValue({ - startedAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - lastSyncedAt: priorLastSyncedAt, - status: "partial", - }) + // coexistService.findResumeCeiling already derives the ceiling from the + // prior run's status (partial → lastSyncedAt) — the handler just consumes + // the resolved Date. + mockFindResumeCeiling.mockResolvedValue(priorLastSyncedAt) const aboveCeilingTs = new Date( priorLastSyncedAt.getTime() + 60 * 1000, @@ -871,7 +840,7 @@ describe("coexistMessengerSync", () => { }) it("conversation fetch failure counts as one failed contact (no N-message inflation)", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // Provide a link so the conv is processed (not skipped for missing link). wireSelectChain(defaultRunRow(), [ @@ -891,32 +860,26 @@ describe("coexistMessengerSync", () => { // bulkImportMessages was not called since the conv failed before reaching it. expect(mockBulkImportMessages).not.toHaveBeenCalled() - // failedCount in the per-page update set should be 1 — not 100. - const allSetPayloads = mockUpdate.mock.results - .flatMap((r) => { - const value = r.value as { set?: ReturnType } | undefined - return value?.set?.mock.calls ?? [] - }) - .map((args) => args[0] as Record) - - // Find the per-page update (phase=messages page N processed) that carries - // the failedCount increment — the "done" finalisation update does not - // include failedCount. - const pageUpdateWithFailure = allSetPayloads.find( - (payload) => - payload && - typeof payload.currentStep === "string" && - (payload.currentStep as string).includes("phase=messages") && - (payload.currentStep as string).includes("processed"), - ) - expect(pageUpdateWithFailure).toBeDefined() - // The sql`` tag mock returns { strings, values }. The increment literal is - // the second interpolation: sql`${model.failedCount} + ${pageFailed}` - // → values = [model.failedCount, pageFailed]. Extract the numeric arg. - const sqlObj = pageUpdateWithFailure?.failedCount as - | { values: unknown[] } - | undefined - expect(sqlObj?.values[1]).toBe(1) + // failedCount in the per-page increment call should be 1 — not 100. The + // atomic `sql\`col + N\`` composition now lives inside + // coexistSyncRunRepository.incrementProgress (unit-tested at that layer); + // here we assert the handler passed the equivalent increment value. + const pageIncrementWithFailure = mockIncrementProgress.mock.calls + .map( + (args) => + args[0] as { + increments?: Record + fields?: Record + }, + ) + .find( + (payload) => + typeof payload.fields?.currentStep === "string" && + (payload.fields.currentStep as string).includes("phase=messages") && + (payload.fields.currentStep as string).includes("processed"), + ) + expect(pageIncrementWithFailure).toBeDefined() + expect(pageIncrementWithFailure?.increments?.failedCount).toBe(1) }) // --------------------------------------------------------------------------- @@ -924,7 +887,7 @@ describe("coexistMessengerSync", () => { // --------------------------------------------------------------------------- it("H1 — caps BUC pause at 300 s even when estimatedTimeToRegainAccess = 3600", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) // The first call to concurrencyForUsage returns 0 (budget exhausted), then @@ -970,7 +933,7 @@ describe("coexistMessengerSync", () => { // --------------------------------------------------------------------------- it("M3 — bulkImportMessages is called once per message page, not once after all pages", async () => { - mockFindFirstMessenger.mockResolvedValue(fakeIntegration) + mockFindByIdMessenger.mockResolvedValue(fakeIntegration) mockFindOrFail.mockResolvedValue(fakeInbox) mockListConversations.mockResolvedValueOnce({ diff --git a/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts b/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts index 93e2b4da57..f70aa9ddce 100644 --- a/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-buffer.test.ts @@ -5,30 +5,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // (vi.mock calls are hoisted to the top of the file by Vitest) // --------------------------------------------------------------------------- -const { mockInsert, mockFindFirst, mockQueueAdd } = vi.hoisted(() => ({ - mockInsert: vi.fn(), - mockFindFirst: vi.fn(), - mockQueueAdd: vi.fn(), -})) +const { mockFindByPhoneNumberId, mockStagePayload, mockQueueAdd } = vi.hoisted( + () => ({ + mockFindByPhoneNumberId: vi.fn(), + mockStagePayload: vi.fn(), + mockQueueAdd: vi.fn(), + }), +) // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockInsert, - update: vi.fn(), - select: vi.fn(), - query: { - integrationWhatsappModel: { findFirst: mockFindFirst }, - integrationMessengerModel: { findFirst: vi.fn() }, - }, +// Plain object stubs — never importOriginal @chatbotx.io/database/schema (it +// opens a real DB connection). This module has no direct model dependency +// left after the refactor, but keep the mock present in case a sibling import +// still resolves through it transitively. +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByPhoneNumberId: mockFindByPhoneNumberId, + }, + whatsappCoexistStagingRepository: { + stagePayload: mockStagePayload, }, - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), - findOrFail: vi.fn(), })) vi.mock("@chatbotx.io/worker-config", () => ({ @@ -44,17 +43,6 @@ vi.mock("@chatbotx.io/worker-config", () => ({ integrationQueue: { add: mockQueueAdd }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - whatsappCoexistStagingModel: { - id: "id", - phoneNumberId: "phoneNumberId", - processedAt: "processedAt", - }, - coexistSyncRunModel: { id: "id" }, - integrationWhatsappModel: {}, - inboxModel: {}, -})) - vi.mock("@chatbotx.io/utils", async (importOriginal) => { const actual = await importOriginal() return { @@ -69,27 +57,6 @@ vi.mock("@chatbotx.io/utils", async (importOriginal) => { import { coexistWhatsappBuffer } from "../src/integration/handlers/coexist/whatsapp-buffer" -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -/** - * Builds a chainable Drizzle insert stub supporting two call patterns: - * 1. staging row: .insert(staging).values(...).onConflictDoNothing() - * 2. run row: .insert(run).values(...).returning([{id:'run-1'}]) - */ -const makeInsertChain = () => { - mockInsert.mockImplementation(() => { - const chain = { - values: vi.fn(), - onConflictDoNothing: vi.fn().mockResolvedValue(undefined), - returning: vi.fn().mockResolvedValue([{ id: "run-1" }]), - } - chain.values.mockReturnValue(chain) - return chain - }) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -100,13 +67,13 @@ describe("coexistWhatsappBuffer", () => { beforeEach(() => { vi.clearAllMocks() - makeInsertChain() + mockStagePayload.mockResolvedValue(undefined) }) it("inserts a row into whatsapp_coexist_staging keyed by phoneNumberId with payload preserved", async () => { // Integration must exist — buffer now validates ownership BEFORE insert to // avoid orphaned staging rows from a webhook with an unknown phoneNumberId. - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ phoneNumberId, coexistEnabled: false, inboxId: "inbox-1", @@ -114,14 +81,14 @@ describe("coexistWhatsappBuffer", () => { await coexistWhatsappBuffer({ phoneNumberId, payload }) - expect(mockInsert).toHaveBeenCalledOnce() - expect(mockInsert.mock.results[0]?.value.values).toHaveBeenCalledWith( + expect(mockStagePayload).toHaveBeenCalledOnce() + expect(mockStagePayload).toHaveBeenCalledWith( expect.objectContaining({ phoneNumberId, payload }), ) }) it("enqueues a single coalesced coexistWhatsappFlush when coexistEnabled === true", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -143,7 +110,7 @@ describe("coexistWhatsappBuffer", () => { }) it("does NOT enqueue flush when coexistEnabled === false", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ phoneNumberId, coexistEnabled: false, inboxId: "inbox-1", @@ -155,7 +122,7 @@ describe("coexistWhatsappBuffer", () => { }) it("does NOT enqueue flush when integration is not found", async () => { - mockFindFirst.mockResolvedValue(null) + mockFindByPhoneNumberId.mockResolvedValue(null) await coexistWhatsappBuffer({ phoneNumberId, payload }) @@ -167,7 +134,7 @@ describe("coexistWhatsappBuffer", () => { // coalesced flush would stay dead for every number that had already flushed. // The generation prefix is the rollout fix. it("the coalesced flush id carries the v2 generation prefix", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -191,8 +158,8 @@ describe("coexistWhatsappBuffer", () => { // tail re-check (see coexistWhatsappFlush), not the buffer's job. // ───────────────────────────────────────────────────────────────────────── - it("enqueues only the coalesced flush — no per-webhook follow-up job", async () => { - mockFindFirst.mockResolvedValue({ + it("M2: enqueues only the coalesced flush — no per-webhook follow-up job", async () => { + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -218,7 +185,7 @@ describe("coexistWhatsappBuffer", () => { // ───────────────────────────────────────────────────────────────────────── it("the coalesced flush removes itself on complete AND on fail so the jobId frees up", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, @@ -240,7 +207,7 @@ describe("coexistWhatsappBuffer", () => { }) it("keeps the delay so burst webhooks still coalesce into one flush", async () => { - mockFindFirst.mockResolvedValue({ + mockFindByPhoneNumberId.mockResolvedValue({ id: "int-1", workspaceId: "ws-1", phoneNumberId, diff --git a/apps/worker/__tests__/get-data-from-json-step.test.ts b/apps/worker/__tests__/get-data-from-json-step.test.ts index 0f007ea556..8e2a6832e1 100644 --- a/apps/worker/__tests__/get-data-from-json-step.test.ts +++ b/apps/worker/__tests__/get-data-from-json-step.test.ts @@ -22,7 +22,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock("@chatbotx.io/business", () => ({ - customFieldService: { findBy: vi.fn() }, + customFieldService: { + findBy: vi.fn(), + findManyByIds: mocks.customFieldFindMany, + }, contactCustomFieldService: { setValues: mocks.setValues, setValueByKey: mocks.setValueByKey, @@ -39,17 +42,6 @@ vi.mock("@chatbotx.io/business/contact-custom-field", () => ({ createSourceTimezoneResolver: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - customFieldModel: { - findFirst: vi.fn(), - findMany: mocks.customFieldFindMany, - }, - }, - }, -})) - vi.mock("@chatbotx.io/variables", () => ({ contactVariableService: { getAll: vi.fn() }, extractVariables: vi.fn(() => []), diff --git a/apps/worker/__tests__/inbox-labels.test.ts b/apps/worker/__tests__/inbox-labels.test.ts index 9e146a43db..5ed9b4c478 100644 --- a/apps/worker/__tests__/inbox-labels.test.ts +++ b/apps/worker/__tests__/inbox-labels.test.ts @@ -3,86 +3,67 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // --------------------------------------------------------------------------- // Mutable state holders (controlled per test) // --------------------------------------------------------------------------- -const queryResults = { - integrationMessengerFindFirst: null as unknown, - integrationZaloFindFirst: null as unknown, - tagModelFindFirst: null as unknown, - tagChannelFindFirst: null as unknown, - contactInboxFindMany: [] as unknown[], +const state = { + messengerIntegration: null as unknown, + zaloIntegration: null as unknown, + tagChannel: undefined as { id: string; tagId: string } | undefined, + contactInboxes: [] as { id: string; contactId: string }[], + ensureTagByNameResult: undefined as string | undefined, + ensureTagChannelResult: undefined as string | undefined, + linkTagToContactsReturningNewResult: [] as { contactId: string }[], } -const insertReturning = { current: [] as unknown[] } // --------------------------------------------------------------------------- -// DB mock — chainable builder +// Mock: @chatbotx.io/business — tagService, tagSyncService, messenger/zalo +// integration services // --------------------------------------------------------------------------- -function makeChain(): Record { - const builder: Record = {} - const noop = () => builder - builder.values = vi.fn(noop) - builder.onConflictDoNothing = vi.fn(noop) - builder.where = vi.fn(noop) - builder.returning = vi.fn(async () => insertReturning.current) - return builder -} -const insertChain = makeChain() -const deleteChain = makeChain() -;(deleteChain.where as ReturnType).mockImplementation( - async () => undefined, +const linkTagToContactsReturningNew = vi.fn( + async () => state.linkTagToContactsReturningNewResult, +) +const recordTagChannelAssignments = vi.fn(async () => undefined) +const deleteTagChannelAssignments = vi.fn(async () => undefined) +const detachTagFromContacts = vi.fn(async () => undefined) +const findTagChannel = vi.fn(async () => state.tagChannel) +const ensureTagByName = vi.fn(async () => state.ensureTagByNameResult) +const ensureTagChannel = vi.fn(async () => state.ensureTagChannelResult) +const enqueueDelete = vi.fn(async () => undefined) +const messengerFindByPageIdUnscoped = vi.fn( + async () => state.messengerIntegration, ) +const zaloFindByOaId = vi.fn(async () => state.zaloIntegration) -// Soft-delete path: db.update(tagModel).set().where().returning() -const updateReturning = { current: [] as unknown[] } -const updateChain: Record = {} -updateChain.set = vi.fn(() => updateChain) -updateChain.where = vi.fn(() => updateChain) -updateChain.returning = vi.fn(async () => updateReturning.current) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - integrationMessengerModel: { - findFirst: vi.fn( - async () => queryResults.integrationMessengerFindFirst, - ), - }, - integrationZaloModel: { - findFirst: vi.fn(async () => queryResults.integrationZaloFindFirst), - }, - tagModel: { - findFirst: vi.fn(async () => queryResults.tagModelFindFirst), - }, - tagChannelModel: { - findFirst: vi.fn(async () => queryResults.tagChannelFindFirst), - }, - contactInboxModel: { - findMany: vi.fn(async () => queryResults.contactInboxFindMany), - }, - }, - insert: vi.fn(() => insertChain), - delete: vi.fn(() => deleteChain), - update: vi.fn(() => updateChain), +vi.mock("@chatbotx.io/business", () => ({ + tagService: { + linkTagToContactsReturningNew: (...args: unknown[]) => + linkTagToContactsReturningNew(...args), + recordTagChannelAssignments: (...args: unknown[]) => + recordTagChannelAssignments(...args), + deleteTagChannelAssignments: (...args: unknown[]) => + deleteTagChannelAssignments(...args), + detachTagFromContacts: (...args: unknown[]) => + detachTagFromContacts(...args), + findTagChannel: (...args: unknown[]) => findTagChannel(...args), + ensureTagByName: (...args: unknown[]) => ensureTagByName(...args), + ensureTagChannel: (...args: unknown[]) => ensureTagChannel(...args), + }, + tagSyncService: { enqueueDelete }, + messengerIntegrationService: { + findByPageIdUnscoped: (...args: unknown[]) => + messengerFindByPageIdUnscoped(...args), + }, + zaloIntegrationService: { + findByOaId: (...args: unknown[]) => zaloFindByOaId(...args), }, - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, - inArray: (...args: unknown[]) => args, - isNull: (...args: unknown[]) => args, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { id: "id", workspaceId: "workspaceId", name: "name" }, - tagChannelModel: { - id: "id", - tagId: "tagId", - channelType: "channelType", - integrationId: "integrationId", - workspaceId: "workspaceId", - externalLabelId: "externalLabelId", - }, - contactsToTagsModel: { contactId: "contactId", tagId: "tagId" }, - contactToTagChannelModel: { - tagId: "tagId", - tagChannelId: "tagChannelId", - contactInboxId: "contactInboxId", +// --------------------------------------------------------------------------- +// Mock: @chatbotx.io/database/repositories +// --------------------------------------------------------------------------- +const listIdsByInboxAndSourceIds = vi.fn(async () => state.contactInboxes) +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactInboxRepository: { + listIdsByInboxAndSourceIds: (...args: unknown[]) => + listIdsByInboxAndSourceIds(...args), }, })) @@ -90,11 +71,6 @@ vi.mock("@chatbotx.io/database/partials", () => ({ channelTypes: { enum: { messenger: "messenger", zalo: "zalo" } }, })) -const enqueueDelete = vi.fn(async () => undefined) -vi.mock("@chatbotx.io/business", () => ({ - tagSyncService: { enqueueDelete }, -})) - const invalidateCacheByTags = vi.fn(async () => undefined) vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags })) @@ -119,23 +95,8 @@ const { handleChannelLabelWebhook } = await import( "../src/integration/handlers/inbox_labels" ) const { logger } = await import("../src/lib/logger") -const { db } = await import("@chatbotx.io/database/client") -const { - tagModel, - tagChannelModel, - contactsToTagsModel, - contactToTagChannelModel, -} = await import("@chatbotx.io/database/schema") - -const dbInsert = db.insert as ReturnType -const dbDelete = db.delete as ReturnType -const dbUpdate = db.update as ReturnType + const loggerWarn = logger.warn as ReturnType -const messengerFindFirst = db.query.integrationMessengerModel - .findFirst as ReturnType -const zaloFindFirst = db.query.integrationZaloModel.findFirst as ReturnType< - typeof vi.fn -> // --------------------------------------------------------------------------- // Fixtures @@ -198,39 +159,28 @@ function zaloData(payload: unknown) { } beforeEach(() => { - queryResults.integrationMessengerFindFirst = null - queryResults.integrationZaloFindFirst = null - queryResults.tagModelFindFirst = null - queryResults.tagChannelFindFirst = null - queryResults.contactInboxFindMany = [] - insertReturning.current = [] - updateReturning.current = [] + state.messengerIntegration = null + state.zaloIntegration = null + state.tagChannel = undefined + state.contactInboxes = [] + state.ensureTagByNameResult = undefined + state.ensureTagChannelResult = undefined + state.linkTagToContactsReturningNewResult = [] idCounter = 0 vi.clearAllMocks() - ;(insertChain.values as ReturnType).mockReturnValue(insertChain) - ;( - insertChain.onConflictDoNothing as ReturnType - ).mockReturnValue(insertChain) - ;(insertChain.returning as ReturnType).mockImplementation( - async () => insertReturning.current, - ) - ;(deleteChain.where as ReturnType).mockImplementation( - async () => undefined, + messengerFindByPageIdUnscoped.mockImplementation( + async () => state.messengerIntegration, ) - ;(updateChain.set as ReturnType).mockReturnValue(updateChain) - ;(updateChain.where as ReturnType).mockReturnValue(updateChain) - ;(updateChain.returning as ReturnType).mockImplementation( - async () => updateReturning.current, + zaloFindByOaId.mockImplementation(async () => state.zaloIntegration) + findTagChannel.mockImplementation(async () => state.tagChannel) + listIdsByInboxAndSourceIds.mockImplementation( + async () => state.contactInboxes, ) - dbInsert.mockReturnValue(insertChain) - dbDelete.mockReturnValue(deleteChain) - dbUpdate.mockReturnValue(updateChain) - messengerFindFirst.mockImplementation( - async () => queryResults.integrationMessengerFindFirst, - ) - zaloFindFirst.mockImplementation( - async () => queryResults.integrationZaloFindFirst, + ensureTagByName.mockImplementation(async () => state.ensureTagByNameResult) + ensureTagChannel.mockImplementation(async () => state.ensureTagChannelResult) + linkTagToContactsReturningNew.mockImplementation( + async () => state.linkTagToContactsReturningNewResult, ) }) @@ -250,11 +200,11 @@ describe("handleChannelLabelWebhook — dispatch", () => { expect.objectContaining({ channel: "telegram" }), "inbox labels: unsupported channel", ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() }) test("stops when integration is not found", async () => { - queryResults.integrationMessengerFindFirst = null + state.messengerIntegration = null await handleChannelLabelWebhook( messengerData({ action: "add", @@ -262,12 +212,12 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() expect(loggerWarn).not.toHaveBeenCalled() }) test("stops when tag sync is disabled", async () => { - queryResults.integrationMessengerFindFirst = messengerIntegration({ + state.messengerIntegration = messengerIntegration({ syncTagEnabledAt: null, }) await handleChannelLabelWebhook( @@ -277,11 +227,11 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() }) test("warns on invalid payload", async () => { - queryResults.integrationMessengerFindFirst = messengerIntegration() + state.messengerIntegration = messengerIntegration() await handleChannelLabelWebhook({ integrationType: "messenger", integrationIdentifier: PAGE_ID, @@ -299,13 +249,13 @@ describe("handleChannelLabelWebhook — dispatch", () => { // =========================================================================== describe("handleChannelLabelWebhook — messenger", () => { beforeEach(() => { - queryResults.integrationMessengerFindFirst = messengerIntegration() + state.messengerIntegration = messengerIntegration() }) test("add assigns + emits applied when the tag channel already exists", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] - insertReturning.current = [{ contactId: "c-1" }] // newly linked + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] + state.linkTagToContactsReturningNewResult = [{ contactId: "c-1" }] // newly linked await handleChannelLabelWebhook( messengerData({ @@ -315,16 +265,24 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1"], + }) + expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + tagId: "tag-1", + tagChannelId: "tc-1", + contactInboxIds: ["ci-1"], + }) expect(emitTagApplied).toHaveBeenCalledWith(WS_ID, "c-1", "tag-1", "ci-1") }) test("add creates tag + channel from page_label_name, then assigns", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null - insertReturning.current = [{ id: "new-id" }] - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] + state.tagChannel = undefined + state.ensureTagByNameResult = "tag-new" + state.ensureTagChannelResult = "tc-new" + state.linkTagToContactsReturningNewResult = [{ contactId: "c-1" }] + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] await handleChannelLabelWebhook( messengerData({ @@ -334,15 +292,31 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(tagModel) - expect(dbInsert).toHaveBeenCalledWith(tagChannelModel) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(ensureTagByName).toHaveBeenCalledWith({ + workspaceId: WS_ID, + name: LABEL_NAME, + }) + expect(ensureTagChannel).toHaveBeenCalledWith({ + workspaceId: WS_ID, + tagId: "tag-new", + channelType: "messenger", + integrationId: "intg-msg-1", + externalLabelId: LABEL_ID, + }) + expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + tagId: "tag-new", + contactIds: ["c-1"], + }) + expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + tagId: "tag-new", + tagChannelId: "tc-new", + contactInboxIds: ["ci-1"], + }) }) test("add skips when tag is missing and no name in payload", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null + state.tagChannel = undefined + state.ensureTagByNameResult = undefined await handleChannelLabelWebhook( messengerData({ @@ -352,7 +326,8 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbInsert).not.toHaveBeenCalled() + // page_label_name defaults to "" -> ensureTagByName is never a valid create path + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() }) test("add without user is a no-op", async () => { @@ -362,12 +337,12 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(dbInsert).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() }) test("remove unassigns: deletes channel mapping + contact tag + emits removed", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [{ id: "ci-1", contactId: "c-1" }] + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] await handleChannelLabelWebhook( messengerData({ @@ -377,8 +352,14 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(dbDelete).toHaveBeenCalledWith(contactToTagChannelModel) - expect(dbDelete).toHaveBeenCalledWith(contactsToTagsModel) + expect(deleteTagChannelAssignments).toHaveBeenCalledWith({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1"], + }) + expect(detachTagFromContacts).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1"], + }) expect(emitTagRemoved).toHaveBeenCalledWith(WS_ID, "c-1", "tag-1", "ci-1") }) @@ -386,7 +367,7 @@ describe("handleChannelLabelWebhook — messenger", () => { await handleChannelLabelWebhook( messengerData({ action: "remove", label: { id: LABEL_ID } }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignments).not.toHaveBeenCalled() }) test("unknown action is a no-op", async () => { @@ -397,8 +378,8 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID }, }), ) - expect(dbInsert).not.toHaveBeenCalled() - expect(dbDelete).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(deleteTagChannelAssignments).not.toHaveBeenCalled() }) }) @@ -407,15 +388,19 @@ describe("handleChannelLabelWebhook — messenger", () => { // =========================================================================== describe("handleChannelLabelWebhook — zalo", () => { beforeEach(() => { - queryResults.integrationZaloFindFirst = zaloIntegration() + state.zaloIntegration = zaloIntegration() }) test("add_user_to_tag assigns the batch of users", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [ + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [ { id: "ci-1", contactId: "c-1" }, { id: "ci-2", contactId: "c-2" }, ] + state.linkTagToContactsReturningNewResult = [ + { contactId: "c-1" }, + { contactId: "c-2" }, + ] await handleChannelLabelWebhook( zaloData({ @@ -425,14 +410,21 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(contactsToTagsModel) - expect(dbInsert).toHaveBeenCalledWith(contactToTagChannelModel) + expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1", "c-2"], + }) + expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + tagId: "tag-1", + tagChannelId: "tc-1", + contactInboxIds: ["ci-1", "ci-2"], + }) }) test("add_user_to_tag with empty user_ids ensures the label only", async () => { - queryResults.tagChannelFindFirst = null - queryResults.tagModelFindFirst = null - insertReturning.current = [{ id: "new-id" }] + state.tagChannel = undefined + state.ensureTagByNameResult = "tag-new" + state.ensureTagChannelResult = "tc-new" await handleChannelLabelWebhook( zaloData({ @@ -442,14 +434,23 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbInsert).toHaveBeenCalledWith(tagModel) - expect(dbInsert).toHaveBeenCalledWith(tagChannelModel) - expect(dbInsert).not.toHaveBeenCalledWith(contactsToTagsModel) + expect(ensureTagByName).toHaveBeenCalledWith({ + workspaceId: WS_ID, + name: LABEL_NAME, + }) + expect(ensureTagChannel).toHaveBeenCalledWith({ + workspaceId: WS_ID, + tagId: "tag-new", + channelType: "zalo", + integrationId: "intg-zalo-1", + externalLabelId: LABEL_NAME, + }) + expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() }) test("remove_user_from_tag unassigns the batch", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } - queryResults.contactInboxFindMany = [ + state.tagChannel = { id: "tc-1", tagId: "tag-1" } + state.contactInboxes = [ { id: "ci-1", contactId: "c-1" }, { id: "ci-2", contactId: "c-2" }, ] @@ -462,8 +463,14 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(dbDelete).toHaveBeenCalledWith(contactToTagChannelModel) - expect(dbDelete).toHaveBeenCalledWith(contactsToTagsModel) + expect(deleteTagChannelAssignments).toHaveBeenCalledWith({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1", "ci-2"], + }) + expect(detachTagFromContacts).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["c-1", "c-2"], + }) expect(emitTagRemoved).toHaveBeenCalledTimes(2) // Each contact attributes to its OWN contactInbox from this label event, // not a shared/most-recent one across the batch. @@ -479,11 +486,11 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME }, }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignments).not.toHaveBeenCalled() }) test("remove_user_from_tag is a no-op when the tag channel is missing", async () => { - queryResults.tagChannelFindFirst = null + state.tagChannel = undefined await handleChannelLabelWebhook( zaloData({ event_name: "remove_user_from_tag", @@ -491,11 +498,11 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME, user_ids: ["u-1"] }, }), ) - expect(dbDelete).not.toHaveBeenCalled() + expect(deleteTagChannelAssignments).not.toHaveBeenCalled() }) test("remove_tag enqueues a channel-scoped delete + keeps the workspace tag", async () => { - queryResults.tagChannelFindFirst = { id: "tc-1", tagId: "tag-1" } + state.tagChannel = { id: "tc-1", tagId: "tag-1" } await handleChannelLabelWebhook( zaloData({ @@ -512,11 +519,11 @@ describe("handleChannelLabelWebhook — zalo", () => { integrationId: "intg-zalo-1", }) // No workspace-wide tag delete from the webhook. - expect(dbUpdate).not.toHaveBeenCalled() + expect(detachTagFromContacts).not.toHaveBeenCalled() }) test("remove_tag is a no-op when the label is not mapped locally", async () => { - queryResults.tagChannelFindFirst = null + state.tagChannel = undefined await handleChannelLabelWebhook( zaloData({ diff --git a/apps/worker/__tests__/integration-worker-incoming-message.test.ts b/apps/worker/__tests__/integration-worker-incoming-message.test.ts index 04aabea7d5..c8759f1e35 100644 --- a/apps/worker/__tests__/integration-worker-incoming-message.test.ts +++ b/apps/worker/__tests__/integration-worker-incoming-message.test.ts @@ -233,6 +233,9 @@ vi.mock("../src/integration/handlers/wait-resume", () => ({ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactInboxRepository: { + findWithContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/automated-response", () => ({ @@ -320,6 +323,9 @@ vi.mock("@chatbotx.io/business", () => ({ conversationService: { findOrCreate: mockConversationFindOrCreate, ensureActive: vi.fn().mockResolvedValue(true), + recordInboundActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), }, workspaceService: { find: vi.fn(), diff --git a/apps/worker/__tests__/read-receipts.test.ts b/apps/worker/__tests__/read-receipts.test.ts index d529537cb4..93530a7bd6 100644 --- a/apps/worker/__tests__/read-receipts.test.ts +++ b/apps/worker/__tests__/read-receipts.test.ts @@ -35,17 +35,12 @@ vi.mock("@chatbotx.io/business", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - }, -})) - vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, getSafeSinceTime: vi.fn((date: Date | null) => date ?? new Date(0)), + contactInboxRepository: { + findWithConversationAndContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/event-bus", () => ({ diff --git a/apps/worker/__tests__/received-message.test.ts b/apps/worker/__tests__/received-message.test.ts index 1015670a68..499e49f5bc 100644 --- a/apps/worker/__tests__/received-message.test.ts +++ b/apps/worker/__tests__/received-message.test.ts @@ -10,8 +10,6 @@ const { mockCreateOrUpdateWithAttachments, mockFindLastByConversation, mockCreateMessageRepository, - mockDbUpdate, - mockFindOrFail, mockFindContactInbox, mockRunChannelHandler, mockBroadcast, @@ -23,15 +21,13 @@ const { mockConversationFindOrCreate, mockAutomatedResponseEnqueueFlowAction, mockIntegrationQueueAdd, - mockDbSet, - mockDbTransaction, - mockDbCount, mockCreateNewContactWithMac, mockWorkspaceFind, mockQuotaIncrement, mockContactUpdate, mockUpdateTracking, mockInvalidateTracking, + mockRecordInboundActivity, mockWorkspaceIsActiveNow, mockAppointmentCancelByToken, mockParseAppointmentCancelPostback, @@ -44,20 +40,7 @@ const { mockRecordProfileRefreshFailure, mockResolveIntegrationContextFromContactInbox, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - const mockDbTransaction = vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ) - const mockDbCount = vi.fn().mockResolvedValue(1) - const mockFindContactInbox = vi.fn() - const mockFindOrFail = vi.fn() const mockRunChannelHandler = vi.fn() @@ -75,9 +58,7 @@ const { mockCreateOrUpdateWithAttachments, mockFindLastByConversation, mockCreateMessageRepository, - mockDbUpdate, mockFindContactInbox, - mockFindOrFail, mockRunChannelHandler, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), @@ -93,9 +74,6 @@ const { .fn() .mockResolvedValue(undefined), mockIntegrationQueueAdd: vi.fn().mockResolvedValue(undefined), - mockDbSet, - mockDbTransaction, - mockDbCount, mockCreateNewContactWithMac: vi.fn(), mockWorkspaceFind: vi.fn().mockResolvedValue(null), mockWorkspaceIsActiveNow: vi.fn().mockReturnValue(true), @@ -104,6 +82,16 @@ const { .fn() .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), + // `conversationService.recordInboundActivity` now owns the transaction + // that used to be `contactInboxService.updateTracking` + a raw + // `db.transaction`/`db.update` on Conversation.lastActivityAt (see + // `persistNewMessageSideEffects`/`recordInboundActivity` in + // packages/business/src/conversation/service.ts). Default resolves a + // tracking invalidation handle so `contactInboxService.invalidateTracking` + // is exercised the same way the real code path does. + mockRecordInboundActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockAppointmentCancelByToken: vi.fn().mockResolvedValue({ cancellable: true, }), @@ -142,6 +130,9 @@ const { vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, + contactInboxRepository: { + findWithContact: mockFindContactInbox, + }, })) vi.mock("@chatbotx.io/automated-response", () => ({ @@ -151,16 +142,6 @@ vi.mock("@chatbotx.io/automated-response", () => ({ })) vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: mockDbUpdate, - query: { - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - $count: mockDbCount, - transaction: mockDbTransaction, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), - findOrFail: mockFindOrFail, isUniqueViolationError: mockIsUniqueViolationError, })) @@ -239,7 +220,10 @@ vi.mock("@chatbotx.io/business", () => ({ unblockIfBlocked: mockContactUnblockIfBlocked, update: mockContactUpdate, }, - conversationService: { findOrCreate: mockConversationFindOrCreate }, + conversationService: { + findOrCreate: mockConversationFindOrCreate, + recordInboundActivity: mockRecordInboundActivity, + }, workspaceService: { find: mockWorkspaceFind, findById: vi.fn().mockResolvedValue({ @@ -537,7 +521,6 @@ describe("receiveMessage — message repository branch", () => { ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -652,21 +635,25 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + // `recordInboundActivity` owns the transaction that used to be a + // standalone `contactInboxService.updateTracking` call plus a raw + // `db.update(conversationModel).set({ lastActivityAt })` — both are now + // internal to the service, so assert the equivalent arguments were + // passed to it (same ids, same tracking values, same activity time). + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastIncomingMessageAt: fakeCreatedMessage.createdAt, lastUserInput: "hello", lastUserInputType: "text", }, - }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: fakeCreatedMessage.createdAt, + contactLocation: null, + at: fakeCreatedMessage.createdAt, }) }) @@ -746,22 +733,21 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) expect(mockContactUnblockIfBlocked).not.toHaveBeenCalled() - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, }, + contactLocation: null, + at: fakeCreatedMessage.createdAt, }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: fakeCreatedMessage.createdAt, - }) - expect(mockUpdateTracking).not.toHaveBeenCalledWith( + expect(mockRecordInboundActivity).not.toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ + tracking: expect.objectContaining({ lastIncomingMessageAt: expect.any(Date), }), }), @@ -829,7 +815,7 @@ describe("receiveMessage — message repository branch", () => { await receiveMessage(baseProps) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockRecordInboundActivity).not.toHaveBeenCalled() expect(mockUpdateTracking).not.toHaveBeenCalled() }) @@ -943,9 +929,9 @@ describe("receiveMessage — message repository branch", () => { expect(mockCreateOrUpdate).toHaveBeenCalledWith( expect.objectContaining({ text: "Xem sản phẩm" }), ) - expect(mockUpdateTracking).toHaveBeenCalledWith( + expect(mockRecordInboundActivity).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ lastBtnTitle: "Xem sản phẩm" }), + tracking: expect.objectContaining({ lastBtnTitle: "Xem sản phẩm" }), }), ) expect(mockAutomatedResponseEnqueueFlowAction).toHaveBeenCalledWith({ @@ -1127,7 +1113,6 @@ describe("receiveMessage — new contact MAC gate", () => { vi.clearAllMocks() // No existing contact inbox → new-contact creation path. mockFindContactInbox.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -1625,13 +1610,18 @@ describe("receiveMessage — new contact MAC gate", () => { await receiveMessage(baseProps) - expect(mockUpdateTracking).toHaveBeenCalledTimes(1) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + // The location write is now internal to `recordInboundActivity` (see + // `packages/business/src/conversation/service.ts`), which persists the + // tracking fields AND `Contact.location` in one transaction — assert the + // equivalent arguments were passed to it, rather than reaching into the + // service's own internal `contactService.update` call. + expect(mockRecordInboundActivity).toHaveBeenCalledTimes(1) + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-new", contactId: "contact-new", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastIncomingMessageAt: fakeCreatedMessage.createdAt, @@ -1643,12 +1633,9 @@ describe("receiveMessage — new contact MAC gate", () => { }, lastBtnTitle: "Choose plan", }, + contactLocation: { latitude: 10.75, longitude: 106.66 }, + at: fakeCreatedMessage.createdAt, }) - expect(mockContactUpdate).toHaveBeenCalledWith( - { workspaceId: "ws-1", id: "contact-new" }, - { location: { latitude: 10.75, longitude: 106.66 } }, - expect.any(Object), - ) }) test("does not persist location from outgoing channel echoes", async () => { @@ -1686,6 +1673,9 @@ describe("receiveMessage — new contact MAC gate", () => { await receiveMessage(baseProps) expect(mockContactUpdate).not.toHaveBeenCalled() + expect(mockRecordInboundActivity).toHaveBeenCalledWith( + expect.objectContaining({ contactLocation: null }), + ) }) }) @@ -1700,7 +1690,6 @@ describe("receiveMessage — referral-only events", () => { ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -1837,7 +1826,6 @@ describe("receiveMessage — existing contact profile refresh (post-save)", () = ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( @@ -2719,7 +2707,6 @@ describe("contact source taxonomy", () => { beforeEach(() => { vi.clearAllMocks() mockFindContactInbox.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -2782,19 +2769,20 @@ describe("contact source taxonomy", () => { const rows = await runCapturedNewContactCreate() expect(rows).toContainEqual(expect.objectContaining({ source: "comments" })) - expect(mockUpdateTracking).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordInboundActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-new", contactId: "contact-new", - workspaceId: "ws-1", - data: { + tracking: { firstInteractionAt: fakeCreatedMessage.createdAt, lastMessageAt: fakeCreatedMessage.createdAt, lastCommentMessageId: fakeCreatedMessage.id, lastCommentMessageAt: fakeCreatedMessage.createdAt, }, + contactLocation: undefined, + at: fakeCreatedMessage.createdAt, }) - expect(mockDbCount).not.toHaveBeenCalled() expect( vi .mocked(allIntegrations.messenger?.runAction) @@ -2810,7 +2798,6 @@ describe("contact source taxonomy", () => { describe("receiveMessage — BSUID resolver chain (D3)", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( integrationService.identifyInboxAndIntegrationAuthFromIdentifier, @@ -2931,7 +2918,6 @@ describe("receiveMessage — BSUID resolver chain (D3)", () => { describe("receiveMessage — new BSUID-keyed contact creation (D2/D8/§8.1)", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) mockWorkspaceFind.mockResolvedValue({ ownerId: "owner-1" }) vi.mocked( @@ -3140,7 +3126,6 @@ describe("receiveMessage — outbound automated response on message echoes", () ...fakeContactInbox, contact: fakeContact, }) - mockFindOrFail.mockResolvedValue(fakeConversation) mockConversationFindOrCreate.mockResolvedValue(fakeConversation) vi.mocked( diff --git a/apps/worker/__tests__/resolve-workspace-id.test.ts b/apps/worker/__tests__/resolve-workspace-id.test.ts index 536955b40c..21c7b005a0 100644 --- a/apps/worker/__tests__/resolve-workspace-id.test.ts +++ b/apps/worker/__tests__/resolve-workspace-id.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const findBy = vi.fn() -const findFirst = vi.fn() +const importFindWorkspaceId = vi.fn() const identify = vi.fn() const smartDelayFindById = vi.fn() const findWorkspaceId = vi.fn() @@ -9,11 +9,9 @@ const findWorkspaceId = vi.fn() vi.mock("@chatbotx.io/business", () => ({ conversationService: { findBy }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { query: { importModel: { findFirst } } }, -})) vi.mock("@chatbotx.io/database/repositories", () => ({ createAiWorkspaceScopeRepository: () => ({ findWorkspaceId }), + importRepository: { findWorkspaceId: importFindWorkspaceId }, })) vi.mock("@chatbotx.io/business/smart-delay", () => ({ smartDelayService: { findById: smartDelayFindById }, @@ -28,7 +26,7 @@ const { resolveWorkspaceId } = await import("../src/lib/resolve-workspace-id") beforeEach(() => { findBy.mockReset() - findFirst.mockReset() + importFindWorkspaceId.mockReset() identify.mockReset() smartDelayFindById.mockReset() findWorkspaceId.mockReset() @@ -70,7 +68,7 @@ describe("resolveWorkspaceId", () => { }) test("resolves an import id", async () => { - findFirst.mockResolvedValue({ workspaceId: "workspace-from-import" }) + importFindWorkspaceId.mockResolvedValue("workspace-from-import") await expect(resolveWorkspaceId({ importId: "import-1" })).resolves.toBe( "workspace-from-import", ) diff --git a/apps/worker/__tests__/send-flow-step.test.ts b/apps/worker/__tests__/send-flow-step.test.ts index d6b47f242c..844941c4c3 100644 --- a/apps/worker/__tests__/send-flow-step.test.ts +++ b/apps/worker/__tests__/send-flow-step.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryCreateWithAttachments, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockFindConversation, mockFindContactInbox, mockBroadcast, @@ -21,28 +19,14 @@ const { mockSendMessageToChannel, mockProcessWhatsappTemplate, mockProcessMessengerTemplate, - mockDbSet, - mockRecordOutboundMessage, + mockRecordOutboundFlowStep, + mockRecordOutboundMessageActivity, mockRecordSendFailure, mockInvalidateTracking, mockConversationInvalidate, - mockUpdateFlowStepState, mockFindAppointmentCalendarBySlug, mockSignAppointmentWebviewToken, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - const mockFindConversation = vi.fn() const mockFindContactInbox = vi.fn() @@ -86,8 +70,6 @@ const { mockRepositoryCreate, mockRepositoryCreateWithAttachments, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockFindConversation, mockFindContactInbox, mockBroadcast: vi.fn(), @@ -119,14 +101,15 @@ const { mockProcessMessengerTemplate: vi .fn() .mockResolvedValue({ messageId: "msg-ms" }), - mockDbSet, - mockRecordOutboundMessage: vi + mockRecordOutboundFlowStep: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockRecordOutboundMessageActivity: vi .fn() .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), mockConversationInvalidate: vi.fn().mockResolvedValue(undefined), - mockUpdateFlowStepState: vi.fn().mockResolvedValue(undefined), mockFindAppointmentCalendarBySlug: vi.fn(), mockSignAppointmentWebviewToken: vi.fn().mockResolvedValue("webview-token"), } @@ -163,23 +146,6 @@ vi.mock("@chatbotx.io/analytics", () => ({ }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - conversationModel: { findFirst: mockFindConversation }, - contactInboxModel: { findFirst: mockFindContactInbox }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -193,14 +159,16 @@ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, broadcastToGuestParty: vi.fn().mockResolvedValue(undefined), contactInboxService: { - recordOutboundMessageCreated: mockRecordOutboundMessage, - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), + findByUncached: mockFindContactInbox, + findRecentByContactId: mockFindContactInbox, recordSendFailure: mockRecordSendFailure, invalidateTracking: mockInvalidateTracking, }, conversationService: { + findByIdWithContact: mockFindConversation, invalidate: mockConversationInvalidate, - updateFlowStepState: mockUpdateFlowStepState, + recordOutboundFlowStep: mockRecordOutboundFlowStep, + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, }, resolveTenantSettings: mockresolveTenantSettings, })) @@ -1000,33 +968,22 @@ describe("sendFlowStep", () => { expect(mockRepositoryCreate).not.toHaveBeenCalled() }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await sendFlowStep({ ...baseParams, step: sendTextStep }) - const { messageModel: messageModelMock } = await import( - "@chatbotx.io/database/schema" - ) - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("updates contact inbox lastMessageAt and conversation lastActivityAt after creating a flow message", async () => { await sendFlowStep({ ...baseParams, step: sendTextStep }) const createdMessage = await mockRepositoryCreate.mock.results[0]?.value - expect(mockRecordOutboundMessage).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordOutboundFlowStep).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", at: createdMessage.createdAt, - }) - expect(mockUpdateFlowStepState).toHaveBeenCalledWith({ - tx: expect.any(Object), - workspaceId: "ws-1", - conversationId: "conv-1", - lastActivityAt: createdMessage.createdAt, lastStep: undefined, currentStep: "step-1", }) @@ -1216,15 +1173,15 @@ describe("sendChatMessage", () => { }) const createdMessage = await mockRepositoryCreate.mock.results[0]?.value - expect(mockRecordOutboundMessage).toHaveBeenCalledWith({ - tx: expect.any(Object), + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", contactInboxId: "ci-1", contactId: "contact-1", - workspaceId: "ws-1", at: createdMessage.createdAt, }) - expect(mockDbSet).toHaveBeenCalledWith({ - lastActivityAt: createdMessage.createdAt, + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], }) }) diff --git a/apps/worker/__tests__/send-messenger-template-create.test.ts b/apps/worker/__tests__/send-messenger-template-create.test.ts index becfe3826f..377f0fe4c7 100644 --- a/apps/worker/__tests__/send-messenger-template-create.test.ts +++ b/apps/worker/__tests__/send-messenger-template-create.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast, mockEmit, mockValidateTemplate, @@ -17,24 +15,13 @@ const { mockContactVariables, mockSendFlowStep, mockRecordSendFailure, - mockDbSet, + mockRecordOutboundMessageActivity, + mockInvalidateTracking, + mockFindAnyActiveFlow, mockEnqueueIntegrationJob, mockFindSendableBroadcast, mockResetContactForResume, } = vi.hoisted(() => { - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - const mockRepositoryCreate = vi.fn().mockResolvedValue({ id: "msg-created", contactInboxId: "ci-1", @@ -60,8 +47,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), mockValidateTemplate: vi.fn().mockResolvedValue({ @@ -81,7 +66,11 @@ const { .fn() .mockResolvedValue({ messageIds: ["provider-msg-1"] }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), - mockDbSet, + mockRecordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), + mockFindAnyActiveFlow: vi.fn().mockResolvedValue(null), mockFindSendableBroadcast: vi.fn().mockResolvedValue({ id: "broadcast-1" }), mockResetContactForResume: vi.fn().mockResolvedValue(undefined), } @@ -95,22 +84,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - flowModel: { findFirst: vi.fn().mockResolvedValue(null) }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -120,12 +93,14 @@ vi.mock("@chatbotx.io/database/schema", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: mockRecordSendFailure, - invalidateTracking: vi.fn().mockResolvedValue(undefined), + invalidateTracking: mockInvalidateTracking, + }, + conversationService: { + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, + }, + flowService: { + findAnyActive: mockFindAnyActiveFlow, }, broadcastService: { findSendableBroadcast: mockFindSendableBroadcast, @@ -304,18 +279,14 @@ describe("processMessengerTemplate", () => { ) }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await processMessengerTemplate({ conversation: fakeConversation, contactInbox: fakeContactInbox, template: fakeTemplate, }) - const messageModelMock = (await import("@chatbotx.io/database/schema")) - .messageModel - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("broadcasts realtime event after message created", async () => { @@ -347,8 +318,16 @@ describe("processMessengerTemplate", () => { "ws-1", createdAt, ) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbSet).toHaveBeenCalledWith({ lastActivityAt: createdAt }) + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + contactId: undefined, + at: createdAt, + }) + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], + }) }) test("does not rethrow when persisting sourceId fails after a successful send", async () => { diff --git a/apps/worker/__tests__/send-messenger-template.test.ts b/apps/worker/__tests__/send-messenger-template.test.ts index 12e05b75d5..8f5df67815 100644 --- a/apps/worker/__tests__/send-messenger-template.test.ts +++ b/apps/worker/__tests__/send-messenger-template.test.ts @@ -85,13 +85,14 @@ vi.mock("@chatbotx.io/variables", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: vi.fn(), contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: vi.fn().mockResolvedValue(undefined), invalidateTracking: vi.fn().mockResolvedValue(undefined), }, + conversationService: { + recordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + }, })) vi.mock("../src/lib/logger", () => ({ @@ -211,10 +212,7 @@ describe("processMessengerTemplate — sourceId persistence", () => { template: TEMPLATE, }) - const setCall = mockDbUpdate.mock.results[0].value.set - expect(setCall).not.toHaveBeenCalledWith( - expect.objectContaining({ sourceId: expect.any(String) }), - ) + expect(mockDbUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/worker/__tests__/send-whatsapp-template.test.ts b/apps/worker/__tests__/send-whatsapp-template.test.ts index 1152ac34fa..16c7828f5f 100644 --- a/apps/worker/__tests__/send-whatsapp-template.test.ts +++ b/apps/worker/__tests__/send-whatsapp-template.test.ts @@ -8,8 +8,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast, mockEmit, mockValidateTemplate, @@ -19,24 +17,12 @@ const { mockConvertButtons, mockParseSdkError, mockRecordSendFailure, - mockDbSet, + mockRecordOutboundMessageActivity, + mockInvalidateTracking, mockEnqueueIntegrationJob, mockFindSendableBroadcast, mockResetContactForResume, } = vi.hoisted(() => { - const mockDbSet = vi.fn() - const updateChain = { set: mockDbSet, where: vi.fn() } - updateChain.set.mockReturnValue(updateChain) - updateChain.where.mockResolvedValue(undefined) - const mockDbUpdate = vi.fn().mockReturnValue(updateChain) - - const insertChain = { - values: vi.fn(), - returning: vi.fn().mockResolvedValue([]), - } - insertChain.values.mockReturnValue(insertChain) - const mockDbInsert = vi.fn().mockReturnValue(insertChain) - const mockRepositoryCreate = vi.fn().mockResolvedValue({ id: "msg-created", contactInboxId: "ci-1", @@ -63,8 +49,6 @@ const { mockRepositoryCreate, mockRepositoryUpdateSourceId, mockCreateMessageRepository, - mockDbInsert, - mockDbUpdate, mockBroadcast: vi.fn(), mockEmit: vi.fn().mockResolvedValue(undefined), mockValidateTemplate: vi.fn().mockResolvedValue({ @@ -84,7 +68,10 @@ const { mockConvertButtons: vi.fn().mockReturnValue([]), mockParseSdkError: vi.fn().mockResolvedValue({ message: "sdk error" }), mockRecordSendFailure: vi.fn().mockResolvedValue(undefined), - mockDbSet, + mockRecordOutboundMessageActivity: vi + .fn() + .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), + mockInvalidateTracking: vi.fn().mockResolvedValue(undefined), mockEnqueueIntegrationJob: vi.fn().mockResolvedValue(undefined), mockFindSendableBroadcast: vi.fn().mockResolvedValue({ id: "broadcast-1" }), mockResetContactForResume: vi.fn().mockResolvedValue(undefined), @@ -99,22 +86,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: mockCreateMessageRepository, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - update: mockDbUpdate, - transaction: vi - .fn() - .mockImplementation((fn: (tx: unknown) => unknown) => - fn({ update: mockDbUpdate }), - ), - query: { - conversationModel: { findFirst: vi.fn().mockResolvedValue(null) }, - }, - }, - eq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ messageModel: { id: "id", sourceId: "sourceId" }, contactInboxModel: { id: "id" }, @@ -131,12 +102,11 @@ vi.mock("@chatbotx.io/worker-config", () => ({ vi.mock("@chatbotx.io/business", () => ({ broadcastToWorkspaceParty: mockBroadcast, contactInboxService: { - recordOutboundMessageCreated: vi - .fn() - .mockResolvedValue({ cacheTags: ["contacts:contact-1:contact-inboxes"] }), - recordOutboundMessageSent: vi.fn().mockResolvedValue(undefined), recordSendFailure: mockRecordSendFailure, - invalidateTracking: vi.fn().mockResolvedValue(undefined), + invalidateTracking: mockInvalidateTracking, + }, + conversationService: { + recordOutboundMessageActivity: mockRecordOutboundMessageActivity, }, broadcastService: { findSendableBroadcast: mockFindSendableBroadcast, @@ -307,18 +277,14 @@ describe("processWhatsappTemplate", () => { ) }) - test("does NOT call db.insert directly for message creation", async () => { + test("does NOT call db.insert directly for message creation — goes through the message repository", async () => { await processWhatsappTemplate({ conversation: fakeConversation, contactInbox: fakeContactInbox, template: fakeTemplate, }) - const messageModelMock = (await import("@chatbotx.io/database/schema")) - .messageModel - for (const call of mockDbInsert.mock.calls) { - expect(call[0]).not.toBe(messageModelMock) - } + expect(mockRepositoryCreate).toHaveBeenCalledTimes(1) }) test("broadcasts realtime event after message created", async () => { @@ -399,8 +365,16 @@ describe("processWhatsappTemplate", () => { "ws-1", createdAt, ) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbSet).toHaveBeenCalledWith({ lastActivityAt: createdAt }) + expect(mockRecordOutboundMessageActivity).toHaveBeenCalledWith({ + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + contactId: undefined, + at: createdAt, + }) + expect(mockInvalidateTracking).toHaveBeenCalledWith({ + cacheTags: ["contacts:contact-1:contact-inboxes"], + }) }) test("does not rethrow when persisting sourceId fails after a successful send", async () => { diff --git a/apps/worker/__tests__/sequence-flow.test.ts b/apps/worker/__tests__/sequence-flow.test.ts index ad85c8ed9e..2f6c944d60 100644 --- a/apps/worker/__tests__/sequence-flow.test.ts +++ b/apps/worker/__tests__/sequence-flow.test.ts @@ -1,47 +1,6 @@ import type { Job } from "bullmq" import { beforeEach, describe, expect, test, vi } from "vitest" -// ---------- db chain spies ---------- -const dbFindFirstSpy = vi.fn() -const dbUpdateSpy = vi.fn() -const dbSetSpy = vi.fn() -const dbWhereSpy = vi.fn() -const dbWhereResolveSpy = vi.fn() - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - sequenceDispatchModel: { - findFirst: (...args: unknown[]) => dbFindFirstSpy(...args), - }, - }, - update: (table: unknown) => { - dbUpdateSpy(table) - return { - set: (values: unknown) => { - dbSetSpy(values) - return { - where: (...args: unknown[]) => { - dbWhereSpy(...args) - return dbWhereResolveSpy(...args) - }, - } - }, - } - }, - }, - and: (...args: unknown[]) => ({ __and: args }), - eq: (col: unknown, val: unknown) => ({ __eq: [col, val] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceDispatchModel: { - id: { __col: "id" }, - workspaceId: { __col: "workspaceId" }, - status: { __col: "status" }, - }, -})) - // ---------- scheduler / redis spies ---------- // removeFromScheduleSpy is accessed at instance-creation time (during tests, // not at import time), so vi.hoisted is not needed. @@ -66,6 +25,24 @@ vi.mock("@chatbotx.io/sequence-scheduler", () => ({ advanceEnrollment: (...args: unknown[]) => advanceEnrollmentSpy(...args), })) +// ---------- contactSequenceService spies ---------- +// sequence-flow.ts now delegates all dispatch persistence to +// contactSequenceService.{findRunningDispatch,markDispatchCompleted, +// markDispatchCanceled,markDispatchFailed}. +const findRunningSpy = vi.fn() +const markCompletedSpy = vi.fn() +const markCanceledSpy = vi.fn() +const markFailedSpy = vi.fn() + +vi.mock("@chatbotx.io/business/contact-sequence", () => ({ + contactSequenceService: { + findRunningDispatch: (...args: unknown[]) => findRunningSpy(...args), + markDispatchCompleted: (...args: unknown[]) => markCompletedSpy(...args), + markDispatchCanceled: (...args: unknown[]) => markCanceledSpy(...args), + markDispatchFailed: (...args: unknown[]) => markFailedSpy(...args), + }, +})) + // ---------- step executor spy (module-level singleton in source) ---------- // Must use vi.hoisted() so the spies exist before the class field initializers // fire at module-import time (sequence-flow.ts does `new StepExecutorService()` @@ -152,9 +129,11 @@ function makeStep(overrides: Record = {}) { } beforeEach(() => { - // db defaults - dbFindFirstSpy.mockResolvedValue(makeDispatch()) - dbWhereResolveSpy.mockResolvedValue(undefined) + // sequence-scheduler defaults + findRunningSpy.mockResolvedValue(makeDispatch()) + markCompletedSpy.mockResolvedValue(undefined) + markCanceledSpy.mockResolvedValue(undefined) + markFailedSpy.mockResolvedValue(undefined) // scheduler defaults removeFromScheduleSpy.mockResolvedValue(undefined) @@ -185,11 +164,13 @@ describe("handleSendSequenceFlow", () => { contactId: "contact-1", }), ) - const setArg = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "completed", + expect(markCompletedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + sentAt: expect.any(Date), + }), ) - expect(setArg).toBeDefined() }) test("calls advanceEnrollment with correct enrollment + step info", async () => { @@ -222,7 +203,7 @@ describe("handleSendSequenceFlow", () => { test("skips sendFlowDirect and reuses the existing sentAt", async () => { // Arrange const completedAt = new Date("2025-01-01T10:00:00Z") - dbFindFirstSpy.mockResolvedValue(makeDispatch({ completedAt })) + findRunningSpy.mockResolvedValue(makeDispatch({ completedAt })) // Act await handleSendSequenceFlow(makeData(), makeJob()) @@ -238,13 +219,15 @@ describe("handleSendSequenceFlow", () => { describe("dispatch not found", () => { test("returns early without touching db, scheduler, or advanceEnrollment", async () => { // Arrange - dbFindFirstSpy.mockResolvedValue(undefined) + findRunningSpy.mockResolvedValue(undefined) // Act await handleSendSequenceFlow(makeData(), makeJob()) // Assert - expect(dbUpdateSpy).not.toHaveBeenCalled() + expect(markCompletedSpy).not.toHaveBeenCalled() + expect(markCanceledSpy).not.toHaveBeenCalled() + expect(markFailedSpy).not.toHaveBeenCalled() expect(sendFlowDirectSpy).not.toHaveBeenCalled() expect(advanceEnrollmentSpy).not.toHaveBeenCalled() expect(removeFromScheduleSpy).not.toHaveBeenCalled() @@ -263,15 +246,13 @@ describe("handleSendSequenceFlow", () => { await handleSendSequenceFlow(makeData(), makeJob()) // Assert - const canceledSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "canceled", + expect(markCanceledSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + reason: "step_inactive", + }), ) - expect(canceledSet).toBeDefined() - expect((canceledSet as unknown[])[0]).toMatchObject({ - status: "canceled", - lastError: "step_inactive", - }) }) test("still calls advanceEnrollment so enrollment progresses past the dead step", async () => { @@ -313,11 +294,13 @@ describe("handleSendSequenceFlow", () => { await handleSendSequenceFlow(makeData(), makeJob()) // Assert - const canceledSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => - (c[0] as Record).status === "canceled", + expect(markCanceledSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + reason: "step_not_found", + }), ) - expect(canceledSet).toBeDefined() }) test("does NOT call advanceEnrollment — no step to advance past", async () => { @@ -352,14 +335,13 @@ describe("handleSendSequenceFlow", () => { ) // Assert - const failedSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => (c[0] as Record).status === "failed", + expect(markFailedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchId: "dispatch-1", + workspaceId: "ws-1", + errorMessage: "send failed", + }), ) - expect(failedSet).toBeDefined() - expect((failedSet as unknown[])[0]).toMatchObject({ - status: "failed", - lastError: "send failed", - }) }) test("removes dispatch from schedule during terminal cleanup", async () => { @@ -399,10 +381,7 @@ describe("handleSendSequenceFlow", () => { ) // Assert — no terminal cleanup - const failedSet = dbSetSpy.mock.calls.find( - (c: unknown[]) => (c[0] as Record).status === "failed", - ) - expect(failedSet).toBeUndefined() + expect(markFailedSpy).not.toHaveBeenCalled() }) test("logs the error with attempt and isFinalAttempt info", async () => { diff --git a/apps/worker/src/ai-agent/handlers/summarize-conversation.ts b/apps/worker/src/ai-agent/handlers/summarize-conversation.ts index b98353f35e..f8dabc1099 100644 --- a/apps/worker/src/ai-agent/handlers/summarize-conversation.ts +++ b/apps/worker/src/ai-agent/handlers/summarize-conversation.ts @@ -5,7 +5,7 @@ import { isSameContextMessage, summarizeConversation, } from "@chatbotx.io/ai/server" -import { db } from "@chatbotx.io/database/client" +import { conversationService } from "@chatbotx.io/business" import { AIJobAction, type AIJobSummarizeConversation, @@ -124,7 +124,7 @@ export async function handleSummarizeConversation( return } - const conversation = await db.query.conversationModel.findFirst({ + const conversation = await conversationService.findBy({ where: { id: conversationId }, }) diff --git a/apps/worker/src/chat/handlers/send-flow-step.ts b/apps/worker/src/chat/handlers/send-flow-step.ts index 9f860bef57..fc1efb3562 100644 --- a/apps/worker/src/chat/handlers/send-flow-step.ts +++ b/apps/worker/src/chat/handlers/send-flow-step.ts @@ -13,7 +13,6 @@ import { resolveTenantSettings, } from "@chatbotx.io/business" import { getPublicFileUrl } from "@chatbotx.io/business/utils" -import { db, eq } from "@chatbotx.io/database/client" import { channelTypes, contentTypes, @@ -24,10 +23,7 @@ import { createMessageRepository, type MessageWithAttachments, } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { AttachmentModel, MessageModel } from "@chatbotx.io/database/types" import { signAppointmentWebviewToken } from "@chatbotx.io/encryption" import { emit } from "@chatbotx.io/event-bus" @@ -109,29 +105,21 @@ const extractBookingPublicLinkSlug = (url: string): string | null => { } const findTargetContactInbox = ({ + workspaceId, contactId, contactInboxId, }: { + workspaceId: string contactId: string contactInboxId?: string }) => { if (contactInboxId) { - return db.query.contactInboxModel.findFirst({ - where: { - id: contactInboxId, - contactId, - }, + return contactInboxService.findByUncached({ + where: { id: contactInboxId, contactId }, }) } - return db.query.contactInboxModel.findFirst({ - where: { - contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, - }) + return contactInboxService.findRecentByContactId({ workspaceId, contactId }) } export const convertButtonsToTemplate = (props: { @@ -338,15 +326,15 @@ export async function sendFlowStep({ commentAnchor, appointmentId, }: ChatJobSendFlowStep["data"]) { - const conversation = await db.query.conversationModel.findFirst({ - where: { id: conversationId }, - with: { contact: true }, + const conversation = await conversationService.findByIdWithContact({ + id: conversationId, }) if (!conversation) { return } const targetContactInbox = await findTargetContactInbox({ + workspaceId: conversation.workspaceId, contactId: conversation.contactId, contactInboxId, }) @@ -657,27 +645,16 @@ export async function sendFlowStep({ } const createdMessage = message - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: targetContactInbox.id, - contactId: targetContactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await conversationService.updateFlowStepState({ - tx, + const trackingInvalidation = + await conversationService.recordOutboundFlowStep({ workspaceId: conversation.workspaceId, conversationId: conversation.id, - lastActivityAt: createdMessage.createdAt, + contactInboxId: targetContactInbox.id, + contactId: targetContactInbox.contactId, + at: createdMessage.createdAt, lastStep: conversation.currentStep, currentStep: resolvedStep.id, }) - - return invalidation - }) await Promise.all([ trackingInvalidation ? contactInboxService.invalidateTracking(trackingInvalidation) @@ -886,13 +863,9 @@ export const sendChatMessage = async ( const contactInbox = targetContactInbox ?? - (await db.query.contactInboxModel.findFirst({ - where: { - contactId: conversation.contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId: conversation.workspaceId, + contactId: conversation.contactId, })) if (!contactInbox) { throw new IntegrationException( @@ -977,23 +950,14 @@ export const sendChatMessage = async ( })) } - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: message.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: message.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: message.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } diff --git a/apps/worker/src/chat/handlers/send-messenger-template.ts b/apps/worker/src/chat/handlers/send-messenger-template.ts index b7ea6f0cc7..b53d2b6ef4 100644 --- a/apps/worker/src/chat/handlers/send-messenger-template.ts +++ b/apps/worker/src/chat/handlers/send-messenger-template.ts @@ -1,13 +1,11 @@ import { broadcastToWorkspaceParty, contactInboxService, + conversationService, + flowService, } from "@chatbotx.io/business" -import { db, eq } from "@chatbotx.io/database/client" import { createMessageRepository } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, ConversationModel, @@ -204,23 +202,14 @@ export async function processMessengerTemplate( }) const createdMessage = newMessage - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: createdMessage.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: createdMessage.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } @@ -429,8 +418,8 @@ export async function sendMessengerTemplateMessage( // nodes, so getNodeFromButton returns undefined → graceful no-op return. // This mirrors how send-text step buttons with no action work. const contextFlow = storedButtons?.some((b) => !b.flowId) - ? await db.query.flowModel.findFirst({ - where: { workspaceId: conversation.workspaceId, active: true }, + ? await flowService.findAnyActive({ + workspaceId: conversation.workspaceId, }) : null diff --git a/apps/worker/src/chat/handlers/send-whatsapp-template.ts b/apps/worker/src/chat/handlers/send-whatsapp-template.ts index 018360ea71..41e4eb996f 100644 --- a/apps/worker/src/chat/handlers/send-whatsapp-template.ts +++ b/apps/worker/src/chat/handlers/send-whatsapp-template.ts @@ -1,13 +1,10 @@ import { broadcastToWorkspaceParty, contactInboxService, + conversationService, } from "@chatbotx.io/business" -import { db, eq } from "@chatbotx.io/database/client" import { createMessageRepository } from "@chatbotx.io/database/repositories" -import { - conversationModel, - type messageModel, -} from "@chatbotx.io/database/schema" +import type { messageModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, ConversationModel, @@ -298,23 +295,14 @@ export async function processWhatsappTemplate( } const createdMessage = newMessage - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = - await contactInboxService.recordOutboundMessageCreated({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: conversation.workspaceId, - at: createdMessage.createdAt, - }) - - await tx - .update(conversationModel) - .set({ lastActivityAt: createdMessage.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation - }) + const trackingInvalidation = + await conversationService.recordOutboundMessageActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + at: createdMessage.createdAt, + }) if (trackingInvalidation) { await contactInboxService.invalidateTracking(trackingInvalidation) } diff --git a/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts b/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts index 599cf0b619..2a825de1c7 100644 --- a/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts +++ b/apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts @@ -1,33 +1,21 @@ // biome-ignore-all lint/suspicious/noBitwiseOperators: bit-packing 63-bit snowflake IDs import { + coexistImportService, contactInboxService, conversationService, - messageCleanupService, workspaceUsageService, } from "@chatbotx.io/business" -import { buildContactInboxIdentityWhere } from "@chatbotx.io/business/contact-inbox" -import { - and, - type DatabaseClient, - db, - describeDatabaseError, - eq, - inArray, - sql, -} from "@chatbotx.io/database/client" -import { contactSources } from "@chatbotx.io/database/partials" +import { describeDatabaseError } from "@chatbotx.io/database/client" import type { BulkCreateAttachmentInput, CreateMessageInput, IMessageRepository, } from "@chatbotx.io/database/repositories" -import { createMessageRepository } from "@chatbotx.io/database/repositories" import { - contactInboxModel, - contactModel, - conversationModel, -} from "@chatbotx.io/database/schema" + contactRepository, + createMessageRepository, +} from "@chatbotx.io/database/repositories" import type { InboxModel } from "@chatbotx.io/database/types" import { emit } from "@chatbotx.io/event-bus" import { emitContactCreated } from "@chatbotx.io/events" @@ -446,8 +434,8 @@ const mergeConversationUpdate = ( * row can exist purely to carry `aiMarkerMessageId` — but still reach the * Conversation update so the marker still advances. * - * The conversation UPDATE routes through `conversationService` (not raw - * `db.execute`) so it gets the same advance-only, NULL-guarded semantics for + * The conversation UPDATE routes through `conversationService` (not a raw + * SQL execute) so it gets the same advance-only, NULL-guarded semantics for * both columns plus the required cache invalidation. Failures must * propagate: otherwise a coexist run can be marked succeeded while these * denormalized activity columns remain null or stuck at row-creation time. @@ -540,65 +528,6 @@ export type BulkImportHistoricalResult = { * created). Callers use this map to dispatch downstream avatar / message * fetches without an additional DB lookup. */ -type ContactInboxIdentityRow = { - id: string - sourceId: string - sourceUserId: string | null - contactId: string -} - -const rowsBySourceUserId = ( - rows: readonly T[], -): Map => - new Map( - rows.flatMap((row) => - row.sourceUserId === null ? [] : [[row.sourceUserId, row] as const], - ), - ) - -/** - * Resolves raced entries whose ContactInbox insert was skipped by the partial - * (inboxId, sourceUserId) unique index: finds the winner row owning each - * entry's scoped user id, keyed by the entry's own import sourceId so the - * caller can alias the import key to the winner's link. - */ -const resolveScopedIdRaceWinners = async (props: { - tx: DatabaseClient - inboxId: string - racedEntries: ReadonlyArray -}): Promise> => { - const { tx, inboxId, racedEntries } = props - if (racedEntries.length === 0) { - return new Map() - } - const winners = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - sourceUserId: contactInboxModel.sourceUserId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - and( - eq(contactInboxModel.inboxId, inboxId), - inArray( - contactInboxModel.sourceUserId, - racedEntries.map(([, scopedId]) => scopedId), - ), - ), - ) - const winnerByScopedId = rowsBySourceUserId(winners) - const aliases = new Map() - for (const [entrySourceId, scopedId] of racedEntries) { - const winner = winnerByScopedId.get(scopedId) - if (winner) { - aliases.set(entrySourceId, winner) - } - } - return aliases -} - export const bulkImportContacts = async (props: { inbox: InboxModel workspaceId: string @@ -646,23 +575,8 @@ export const bulkImportContacts = async (props: { } const sourceIds = [...dedup.keys()] - const newContactCreatedEvents: Array<{ - workspaceId: string - contactId: string - contactInboxId: string - sourceId: string - firstName?: string - phoneNumber?: string - email?: string - channel: string - source: string - createdAt: Date - }> = [] - - let importedContacts = 0 const skippedContacts = 0 const failureReason: string | undefined = undefined - const contactInboxIds = new Map() // A thread's scoped user id (e.g. a WhatsApp BSUID) may already belong to a // row in this inbox under a different sourceId. Matching on it up front @@ -672,301 +586,15 @@ export const bulkImportContacts = async (props: { entry.sourceUserId ? [entry.sourceUserId] : [], ) - await db.transaction(async (tx) => { - // 1. Find existing ContactInbox rows — by sourceId or scoped user id. - const existingRows = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - sourceUserId: contactInboxModel.sourceUserId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - buildContactInboxIdentityWhere({ - inboxId: inbox.id, - sourceIds, - sourceUserIds, - }), - ) - - const resolved = new Map() - const existingContactIds = new Set() - - for (const row of existingRows) { - existingContactIds.add(row.contactId) - resolved.set(row.sourceId, { - contactInboxId: row.id, - contactId: row.contactId, - conversationId: "", - }) - } - - const existingBySourceUserId = rowsBySourceUserId(existingRows) - for (const [sourceId, entry] of dedup) { - if (resolved.has(sourceId) || !entry.sourceUserId) { - continue - } - const row = existingBySourceUserId.get(entry.sourceUserId) - if (!row) { - continue - } - existingContactIds.add(row.contactId) - resolved.set(sourceId, { - contactInboxId: row.id, - contactId: row.contactId, - conversationId: "", - }) - } - - // Resolve conversation ids for existing contacts. Heal orphans (existing - // ContactInbox + Contact but missing Conversation) by inserting one now, - // so downstream callers never receive an empty conversationId. - if (existingContactIds.size > 0) { - const conversations = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, [...existingContactIds])) - const convByContact = new Map( - conversations.map((c) => [c.contactId, c.id]), - ) - - const orphanContactIds = [...existingContactIds].filter( - (cid) => !convByContact.has(cid), - ) - if (orphanContactIds.length > 0) { - await tx - .insert(conversationModel) - .values( - orphanContactIds.map((cid) => ({ - id: createId(), - workspaceId, - contactId: cid, - })), - ) - .onConflictDoNothing() - const healed = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, orphanContactIds)) - for (const c of healed) { - convByContact.set(c.contactId, c.id) - } - } - - for (const link of resolved.values()) { - const cid = convByContact.get(link.contactId) - if (cid) { - link.conversationId = cid - } - } - } - - const newEntries = [...dedup.entries()].filter( - ([sourceId]) => !resolved.has(sourceId), - ) - const acceptedNew = newEntries - - // 2. Insert Contact + ContactInbox + Conversation for acceptedNew. - if (acceptedNew.length > 0) { - const contactRows = acceptedNew.map(([, entry]) => ({ - id: createId(), - workspaceId, - firstName: entry.firstName, - lastName: entry.lastName, - email: entry.email, - phoneNumber: entry.phoneNumber, - avatar: entry.avatar, - })) - - await tx.insert(contactModel).values(contactRows) - - const contactInboxRows = acceptedNew.map(([sourceId, entry], i) => ({ - id: createId(), - inboxId: inbox.id, - contactId: contactRows[i]?.id, - originalContactId: contactRows[i]?.id, - source: contactSources.enum.inboundMessage, - sourceId, - sourceUserId: entry.sourceUserId ?? null, - sourceUsername: entry.sourceUsername ?? null, - channel: inbox.channel, - createdAt: new Date(), - updatedAt: new Date(), - })) - - const conversationRows = acceptedNew.map((_entry, i) => ({ - id: createId(), - workspaceId, - contactId: contactRows[i]?.id, - })) - - // Targetless DO NOTHING: a concurrent import can win EITHER identity - // index — (inboxId, sourceId) or the partial (inboxId, sourceUserId) — - // and a targeted clause would let the second one abort the whole batch. - const insertedInboxes = await tx - .insert(contactInboxModel) - .values(contactInboxRows) - .onConflictDoNothing() - .returning({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - contactId: contactInboxModel.contactId, - }) - - const insertedSourceIds = new Set(insertedInboxes.map((r) => r.sourceId)) - - // Race recovery — any acceptedNew sourceId not inserted lost to a - // concurrent insert; re-SELECT winners + delete pre-allocated orphans. - const racedSourceIds = acceptedNew - .map(([sourceId]) => sourceId) - .filter((s) => !insertedSourceIds.has(s)) - - // Maps a raced entry's import key to the winner row that claimed its - // scoped user id under a DIFFERENT sourceId — the final link mapping is - // keyed by row.sourceId, so these aliases are re-keyed at the end. - let scopedWinnerAliases = new Map() - - if (racedSourceIds.length > 0) { - const winners = await tx - .select({ - id: contactInboxModel.id, - sourceId: contactInboxModel.sourceId, - contactId: contactInboxModel.contactId, - }) - .from(contactInboxModel) - .where( - and( - eq(contactInboxModel.inboxId, inbox.id), - inArray(contactInboxModel.sourceId, racedSourceIds), - ), - ) - for (const w of winners) { - insertedInboxes.push(w) - insertedSourceIds.add(w.sourceId) - } - - // A raced row skipped on the scoped-user-id index has no winner under - // its own sourceId — resolve it through the row owning that scoped id. - scopedWinnerAliases = await resolveScopedIdRaceWinners({ - tx, - inboxId: inbox.id, - racedEntries: racedSourceIds.flatMap((sourceId) => { - if (insertedSourceIds.has(sourceId)) { - return [] - } - const scopedId = dedup.get(sourceId)?.sourceUserId - return scopedId ? [[sourceId, scopedId] as const] : [] - }), - }) - for (const winner of scopedWinnerAliases.values()) { - insertedInboxes.push({ - id: winner.id, - sourceId: winner.sourceId, - contactId: winner.contactId, - }) - } - - const racedSet = new Set(racedSourceIds) - const orphanIds: string[] = [] - for (let i = 0; i < acceptedNew.length; i++) { - const sourceId = acceptedNew[i]?.[0] - const contactId = contactRows[i]?.id - if (sourceId && contactId && racedSet.has(sourceId)) { - orphanIds.push(contactId) - } - } - if (orphanIds.length > 0) { - await tx - .delete(contactModel) - .where(inArray(contactModel.id, orphanIds)) - } - } - - // Re-created contacts keep their history: cancel any pending message - // cleanup recorded when contacts with these inbox identities were deleted. - await messageCleanupService.cancelByInboxSource({ - inboxId: inbox.id, - sourceIds: insertedInboxes.map((r) => r.sourceId), - tx, - }) - - const trulyNew = acceptedNew.length - racedSourceIds.length - importedContacts = trulyNew - - const racedSet2 = new Set(racedSourceIds) - const conversationsToInsert = conversationRows.filter( - (_row, i) => !racedSet2.has(acceptedNew[i]?.[0]), - ) - if (conversationsToInsert.length > 0) { - await tx - .insert(conversationModel) - .values(conversationsToInsert) - .onConflictDoNothing() - } - - // Resolve conversation ids for everything just inserted (or raced). - const acceptedContactIds = insertedInboxes.map((r) => r.contactId) - const newConversations = await tx - .select({ - id: conversationModel.id, - contactId: conversationModel.contactId, - }) - .from(conversationModel) - .where(inArray(conversationModel.contactId, acceptedContactIds)) - const convByContactNew = new Map( - newConversations.map((c) => [c.contactId, c.id]), - ) - - for (const inboxRow of insertedInboxes) { - const convId = convByContactNew.get(inboxRow.contactId) - if (!convId) { - continue - } - resolved.set(inboxRow.sourceId, { - contactInboxId: inboxRow.id, - contactId: inboxRow.contactId, - conversationId: convId, - }) - - const entry = dedup.get(inboxRow.sourceId) - if (entry) { - newContactCreatedEvents.push({ - workspaceId, - contactId: inboxRow.contactId, - contactInboxId: inboxRow.id, - sourceId: inboxRow.sourceId, - firstName: entry.firstName, - phoneNumber: entry.phoneNumber, - email: entry.email, - channel: inbox.channel, - source: contactSources.enum.inboundMessage, - createdAt: new Date(), - }) - } - } - - // Scoped-id winners resolve under their own sourceId above; alias the - // raced entry's import key to the same link so downstream message - // imports keyed by the entry's sourceId still find their contact. - for (const [entrySourceId, winner] of scopedWinnerAliases) { - const link = resolved.get(winner.sourceId) - if (link) { - resolved.set(entrySourceId, link) - } - } - } - - for (const [sourceId, link] of resolved) { - contactInboxIds.set(sourceId, link) - } - }) + const { importedContacts, contactInboxIds, newContactCreatedEvents } = + await coexistImportService.resolveOrCreateContactLinks({ + workspaceId, + inboxId: inbox.id, + inboxChannel: inbox.channel, + dedup, + sourceIds, + sourceUserIds, + }) // Post-commit side effects. for (const ev of newContactCreatedEvents) { @@ -1200,19 +828,13 @@ export const bulkImportMessages = async (props: { } } - // Contact enrichment in its own main-DB transaction. + // Contact enrichment — routed through the repository so it gets the exact + // same COALESCE/WHERE-guarded semantics in its own main-DB transaction. if (hasEnrichment && contactEnrichment) { - await db.transaction(async (tx) => { - await tx.execute(sql` - UPDATE "Contact" SET - "phoneNumber" = COALESCE("phoneNumber", ${contactEnrichment.phoneNumber ?? null}::text), - "email" = COALESCE("email", ${contactEnrichment.email ?? null}::text) - WHERE "id" = ${contactId} - AND ( - (${contactEnrichment.phoneNumber ?? null}::text IS NOT NULL AND "phoneNumber" IS NULL) - OR (${contactEnrichment.email ?? null}::text IS NOT NULL AND "email" IS NULL) - ) - `) + await contactRepository.enrichIfNull({ + contactId, + phoneNumber: contactEnrichment.phoneNumber, + email: contactEnrichment.email, }) } diff --git a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts index 2b4a661724..095ccb7a9a 100644 --- a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts +++ b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts @@ -1,21 +1,12 @@ -import { logProviderError } from "@chatbotx.io/business/error-log" -import { - and, - db, - eq, - findOrFail, - inArray, - lt, - ne, - or, - sql, -} from "@chatbotx.io/database/client" import { - coexistSyncRunModel, - contactInboxModel, - conversationModel, - inboxModel, -} from "@chatbotx.io/database/schema" + coexistImportService, + coexistService, + messengerIntegrationService, + workspaceService, +} from "@chatbotx.io/business" +import { logProviderError } from "@chatbotx.io/business/error-log" +import { findOrFail } from "@chatbotx.io/database/client" +import { inboxModel } from "@chatbotx.io/database/schema" import { listConversations, type MessengerConversation, @@ -68,33 +59,6 @@ const DEFAULT_CONCURRENCY = 5 */ const CHUNK_BUDGET_MS = 4 * 60 * 1000 -/** - * Resolves the per-integration resume ceiling from the most recent prior - * `CoexistSyncRun` row. See bulk-historical-import for full semantics. - */ -async function fetchPriorRunCeiling( - integrationId: string, - currentRunId: string, -): Promise { - const priorRun = await db.query.coexistSyncRunModel.findFirst({ - where: { - integrationId, - channel: "messenger", - status: { in: ["succeeded", "partial"] }, - id: { ne: currentRunId }, - }, - orderBy: { startedAt: "desc" }, - columns: { startedAt: true, lastSyncedAt: true, status: true }, - }) - if (!priorRun) { - return null - } - if (priorRun.status === "succeeded") { - return priorRun.startedAt ?? null - } - return priorRun.lastSyncedAt ?? priorRun.startedAt ?? null -} - type ConvFilter = { convsToProcess: MessengerConversation[] stopAll: boolean @@ -237,14 +201,13 @@ async function walkConversationsPages( // final page and the loop will exit without a subsequent respectPause(). await ctx.respectPause() - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { currentStep: `phase=${phaseName} page ${pageNumber} — ${conversations.data.length} conversations`, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) const filtered = filterConversations( conversations.data, @@ -275,11 +238,7 @@ async function walkConversationsPages( async function runContactsPhase(ctx: SyncContext): Promise { const { runId, workspaceId, pageId, inbox } = ctx - const [runRow] = await db - .select({ lastSyncedAt: coexistSyncRunModel.lastSyncedAt }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const runRow = await coexistService.findLastSyncedAt({ runId }) if (!runRow) { return { done: true, oldestConvProcessed: null, pageNumber: 0 } @@ -362,19 +321,20 @@ async function runContactsPhase(ctx: SyncContext): Promise { ctx.errorRef.current = `phase=contacts page ${pageNumber}: ${pageResult.failureReason}` } - await db - .update(coexistSyncRunModel) - .set({ - currentScan: sql`${coexistSyncRunModel.currentScan} + ${filtered.convsToProcess.length}`, - importedContactCount: sql`${coexistSyncRunModel.importedContactCount} + ${pageResult.importedContacts}`, - skippedCount: sql`${coexistSyncRunModel.skippedCount} + ${pageResult.skippedContacts}`, + await coexistService.incrementProgress({ + runId, + increments: { + currentScan: filtered.convsToProcess.length, + importedContactCount: pageResult.importedContacts, + skippedCount: pageResult.skippedContacts, + }, + fields: { lastSyncedAt: filtered.oldestConvProcessed, currentStep: `phase=contacts page ${pageNumber} processed`, currentError: ctx.errorRef.current ?? null, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) return filtered.oldestConvProcessed }, @@ -390,11 +350,7 @@ async function runContactsPhase(ctx: SyncContext): Promise { async function runMessagesPhase(ctx: SyncContext): Promise { const { runId, workspaceId, pageId, inbox } = ctx - const [runRow] = await db - .select({ lastSyncedAt: coexistSyncRunModel.lastSyncedAt }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const runRow = await coexistService.findLastSyncedAt({ runId }) if (!runRow) { return { done: true, oldestConvProcessed: null, pageNumber: 0 } @@ -426,24 +382,10 @@ async function runMessagesPhase(ctx: SyncContext): Promise { // Single JOIN resolves ContactInbox + Conversation in one round trip. const linkBySource = new Map() if (sourceIds.length > 0) { - const rows = await db - .select({ - sourceId: contactInboxModel.sourceId, - contactInboxId: contactInboxModel.id, - contactId: contactInboxModel.contactId, - conversationId: conversationModel.id, - }) - .from(contactInboxModel) - .leftJoin( - conversationModel, - eq(conversationModel.contactId, contactInboxModel.contactId), - ) - .where( - and( - eq(contactInboxModel.inboxId, inbox.id), - inArray(contactInboxModel.sourceId, sourceIds), - ), - ) + const rows = await coexistImportService.listContactLinksBySourceIds({ + inboxId: inbox.id, + sourceIds, + }) for (const r of rows) { if (!r.conversationId) { continue @@ -615,19 +557,20 @@ async function runMessagesPhase(ctx: SyncContext): Promise { } } - await db - .update(coexistSyncRunModel) - .set({ - importedMessageCount: sql`${coexistSyncRunModel.importedMessageCount} + ${pageImported}`, - skippedCount: sql`${coexistSyncRunModel.skippedCount} + ${pageSkipped}`, - failedCount: sql`${coexistSyncRunModel.failedCount} + ${pageFailed}`, + await coexistService.incrementProgress({ + runId, + increments: { + importedMessageCount: pageImported, + skippedCount: pageSkipped, + failedCount: pageFailed, + }, + fields: { lastSyncedAt: pageOldest, currentStep: `phase=messages page ${pageNumber} processed`, currentError: ctx.errorRef.current ?? null, lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) return pageOldest }, @@ -656,19 +599,14 @@ export const coexistMessengerSync = async ( const jobStart = Date.now() const failRun = async (currentError: string): Promise => { - await db - .update(coexistSyncRunModel) - .set({ - status: "failed", - currentError, - finishedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + await coexistService.markFailed({ runId, currentError }) } - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { id: integrationId }, + // NO workspace scope — the mismatch branch below deliberately distinguishes + // "not found" from "workspaceId mismatch" (do NOT substitute a + // workspace-scoped lookup here, which would collapse that distinction). + const integration = await messengerIntegrationService.findById({ + id: integrationId, }) if (!integration) { logger.warn({ integrationId }, "[coexist] Messenger integration gone") @@ -711,54 +649,30 @@ export const coexistMessengerSync = async ( message: "Inbox not found", }) - const workspace = await db.query.workspaceModel.findFirst({ - where: { id: workspaceId }, - columns: { targetCountry: true }, - }) + const workspace = await workspaceService.find({ where: { id: workspaceId } }) const defaultCountry = workspace?.targetCountry ?? null - const [initRow] = await db - .select({ - attempts: coexistSyncRunModel.attempts, - currentError: coexistSyncRunModel.currentError, - messengerSyncPhase: coexistSyncRunModel.messengerSyncPhase, - }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const initRow = await coexistService.findInitState({ runId }) if (!initRow) { logger.warn({ runId }, "[coexist] CoexistSyncRun row gone — abandoning") return } - const ceiling = await fetchPriorRunCeiling(integrationId, runId) + const ceiling = await coexistService.findResumeCeiling({ + integrationId, + channel: "messenger", + currentRunId: runId, + }) const attempts = initRow.attempts // Optimistic claim: only one worker may flip status→running at a time. - const claimed = await db - .update(coexistSyncRunModel) - .set({ - status: "running", - startedAt: sql`COALESCE(${coexistSyncRunModel.startedAt}, NOW())`, - lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(coexistSyncRunModel.id, runId), - or( - ne(coexistSyncRunModel.status, "running"), - lt( - coexistSyncRunModel.lastHeartbeatAt, - sql`NOW() - INTERVAL '10 minutes'`, - ), - ), - ), - ) - .returning({ id: coexistSyncRunModel.id }) + const claimedRun = await coexistService.claimRunForSync({ + runId, + touchUpdatedAt: true, + }) - if (claimed.length === 0) { + if (!claimedRun) { logger.warn( { runId, integrationId }, "[coexist] Messenger run already claimed by another worker — abandoning", @@ -853,16 +767,15 @@ export const coexistMessengerSync = async ( break } // Transition to phase 2 — reset frontier so messages walks from newest. - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { messengerSyncPhase: "messages", lastSyncedAt: null, currentStep: "contacts done — start message phase", lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) currentPhase = "messages" continue } @@ -876,23 +789,15 @@ export const coexistMessengerSync = async ( } // Both phases complete — derive terminal status from counters. - const [terminal] = await db - .select({ - importedMessages: coexistSyncRunModel.importedMessageCount, - skipped: coexistSyncRunModel.skippedCount, - failed: coexistSyncRunModel.failedCount, - }) - .from(coexistSyncRunModel) - .where(eq(coexistSyncRunModel.id, runId)) - .limit(1) + const terminal = await coexistService.findTerminalCounters({ runId }) if ( terminal && - terminal.failed > 0 && - (terminal.importedMessages > 0 || terminal.skipped > 0) + terminal.failedCount > 0 && + (terminal.importedMessageCount > 0 || terminal.skippedCount > 0) ) { finalStatus = "partial" - } else if (terminal && terminal.failed > 0) { + } else if (terminal && terminal.failedCount > 0) { finalStatus = "failed" } else { finalStatus = "succeeded" @@ -924,14 +829,10 @@ export const coexistMessengerSync = async ( { error, runId }, "[coexist] Messenger continuation enqueue failed — fallback to scheduler", ) - await db - .update(coexistSyncRunModel) - .set({ - status: "init", - lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + await coexistService.updateProgress({ + runId, + fields: { status: "init", lastHeartbeatAt: new Date() }, + }) } } } catch (error) { @@ -950,17 +851,16 @@ export const coexistMessengerSync = async ( }) } finally { if (finalStatus !== null) { - await db - .update(coexistSyncRunModel) - .set({ + await coexistService.updateProgress({ + runId, + fields: { status: finalStatus, finishedAt: new Date(), lastHeartbeatAt: new Date(), currentStep: "done", currentError: errorRef.current ?? null, - updatedAt: new Date(), - }) - .where(eq(coexistSyncRunModel.id, runId)) + }, + }) } } diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts index 8ec3817c4c..6f5680ae82 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-buffer.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto" -import { db } from "@chatbotx.io/database/client" -import { whatsappCoexistStagingModel } from "@chatbotx.io/database/schema" +import { + integrationWhatsappRepository, + whatsappCoexistStagingRepository, +} from "@chatbotx.io/database/repositories" import { createId } from "@chatbotx.io/utils" import { buildCoexistFlushJobId, @@ -35,8 +37,8 @@ export const coexistWhatsappBuffer = async ( // Validate ownership BEFORE touching the staging table — otherwise a // spoofed or stale phoneNumberId would orphan rows that no flush can // ever drain (no integration row exists to gate them). - const integration = await db.query.integrationWhatsappModel.findFirst({ - where: { phoneNumberId }, + const integration = await integrationWhatsappRepository.findByPhoneNumberId({ + phoneNumberId, }) if (!integration) { @@ -49,15 +51,12 @@ export const coexistWhatsappBuffer = async ( // Idempotency: Meta retries webhook deliveries. (phoneNumberId, payloadHash) // is uniquely indexed, so duplicate deliveries collapse to one staging row. - await db - .insert(whatsappCoexistStagingModel) - .values({ - id: createId(), - phoneNumberId, - payload, - payloadHash: hashPayload(payload), - }) - .onConflictDoNothing() + await whatsappCoexistStagingRepository.stagePayload({ + id: createId(), + phoneNumberId, + payload, + payloadHash: hashPayload(payload), + }) // Only enqueue flush jobs when coexistEnabled — staging rows are always // persisted (to avoid data loss before the user enables coexist), but the diff --git a/apps/worker/src/integration/handlers/contact/update-avatar.ts b/apps/worker/src/integration/handlers/contact/update-avatar.ts index 1d262a4117..5310bacd77 100644 --- a/apps/worker/src/integration/handlers/contact/update-avatar.ts +++ b/apps/worker/src/integration/handlers/contact/update-avatar.ts @@ -1,5 +1,4 @@ -import { and, db, eq, isNull } from "@chatbotx.io/database/client" -import { contactModel } from "@chatbotx.io/database/schema" +import { contactInboxService, contactService } from "@chatbotx.io/business" import type { IntegrationJobUpdateContactAvatar } from "@chatbotx.io/worker-config" import { logger } from "../../../lib/logger" import { @@ -21,7 +20,7 @@ export const updateContactAvatar = async ( ): Promise => { const { workspaceId, contactInboxId, sourceId } = data - const contactInbox = await db.query.contactInboxModel.findFirst({ + const contactInbox = await contactInboxService.findByUncached({ where: { id: contactInboxId }, }) if (!contactInbox) { @@ -29,9 +28,9 @@ export const updateContactAvatar = async ( return } - const contact = await db.query.contactModel.findFirst({ - where: { id: contactInbox.contactId }, - columns: { id: true, avatar: true }, + const contact = await contactService.findById({ + workspaceId, + id: contactInbox.contactId, }) if (!contact) { logger.warn( @@ -73,8 +72,9 @@ export const updateContactAvatar = async ( return } - await db - .update(contactModel) - .set({ avatar, updatedAt: new Date() }) - .where(and(eq(contactModel.id, contact.id), isNull(contactModel.avatar))) + await contactService.setAvatarIfEmpty({ + workspaceId, + contactId: contact.id, + avatar, + }) } diff --git a/apps/worker/src/integration/handlers/generate-text-agent/index.ts b/apps/worker/src/integration/handlers/generate-text-agent/index.ts index 85007d1040..ab4fe1be92 100644 --- a/apps/worker/src/integration/handlers/generate-text-agent/index.ts +++ b/apps/worker/src/integration/handlers/generate-text-agent/index.ts @@ -1,7 +1,7 @@ import { aiTimeouts } from "@chatbotx.io/ai" import { aiContextService } from "@chatbotx.io/ai/server" +import { aiAgentService } from "@chatbotx.io/business" import { logProviderError } from "@chatbotx.io/business/error-log" -import { db } from "@chatbotx.io/database/client" import { isMessageStorageError } from "@chatbotx.io/database/errors" import { type AIAgentModelConfig, @@ -30,7 +30,7 @@ export async function handleAIGenerateTextAgent({ const timeoutId = setTimeout(() => controller.abort(), aiTimeouts.aiTotal) try { - const aiAgent = await db.query.aiAgentModel.findFirst({ + const aiAgent = await aiAgentService.findBy({ where: { id: step.aiAgentId, workspaceId: conversation.workspaceId, diff --git a/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts b/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts index 34db4554d4..b02706537c 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/channels/messenger.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" import { channelTypes } from "@chatbotx.io/database/partials" import { messengerWebhookEventSchema } from "@chatbotx.io/integration-messenger/schema" import type { Channel } from "../types" @@ -12,8 +12,8 @@ import type { Channel } from "../types" */ export const messengerChannel: Channel = { async loadContext(pageId) { - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { pageId }, + const integration = await messengerIntegrationService.findByPageIdUnscoped({ + pageId, }) if (!integration?.syncTagEnabledAt) { return null diff --git a/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts b/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts index 59c3103ee0..11d4948afa 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/channels/zalo.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { zaloIntegrationService } from "@chatbotx.io/business" import { channelTypes } from "@chatbotx.io/database/partials" import { z } from "zod" import type { Channel } from "../types" @@ -22,9 +22,7 @@ const zaloTagEventSchema = z.object({ export const zaloChannel: Channel = { async loadContext(oaId) { - const integration = await db.query.integrationZaloModel.findFirst({ - where: { oaId }, - }) + const integration = await zaloIntegrationService.findByOaId({ oaId }) if (!integration?.syncTagEnabledAt) { return null } diff --git a/apps/worker/src/integration/handlers/inbox_labels/sync.ts b/apps/worker/src/integration/handlers/inbox_labels/sync.ts index 675014b660..92f9264382 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/sync.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/sync.ts @@ -1,13 +1,6 @@ -import { tagSyncService } from "@chatbotx.io/business" -import { and, db, eq, inArray, isNull } from "@chatbotx.io/database/client" -import { - contactsToTagsModel, - contactToTagChannelModel, - tagChannelModel, - tagModel, -} from "@chatbotx.io/database/schema" +import { tagService, tagSyncService } from "@chatbotx.io/business" +import { contactInboxRepository } from "@chatbotx.io/database/repositories" import { emitTagApplied, emitTagRemoved } from "@chatbotx.io/events" -import { createId } from "@chatbotx.io/utils" import { logger } from "../../../lib/logger" import type { LabelContext, LabelEvent } from "./types" @@ -50,28 +43,17 @@ async function assignLabel( // Link the workspace tag to the contacts; capture the newly-linked ones so we // emit "tag applied" exactly once per new pair (same as add-contact-tag). - const linked = await db - .insert(contactsToTagsModel) - .values( - inboxes.map((inbox) => ({ - contactId: inbox.contactId, - tagId: mapping.tagId, - })), - ) - .onConflictDoNothing() - .returning({ contactId: contactsToTagsModel.contactId }) + const linked = await tagService.linkTagToContactsReturningNew({ + tagId: mapping.tagId, + contactIds: inboxes.map((inbox) => inbox.contactId), + }) // Record the per-channel assignment (used for reconciliation / detach). - await db - .insert(contactToTagChannelModel) - .values( - inboxes.map((inbox) => ({ - tagId: mapping.tagId, - tagChannelId: mapping.tagChannelId, - contactInboxId: inbox.id, - })), - ) - .onConflictDoNothing() + await tagService.recordTagChannelAssignments({ + tagId: mapping.tagId, + tagChannelId: mapping.tagChannelId, + contactInboxIds: inboxes.map((inbox) => inbox.id), + }) // Per-contact contactInboxId map, keyed off the same `inboxes` list used to // build the insert above — each newly-linked contact attributes to the @@ -111,26 +93,17 @@ async function unassignLabel( } // Remove the per-channel assignment record. - await db.delete(contactToTagChannelModel).where( - and( - eq(contactToTagChannelModel.tagChannelId, tagChannel.id), - inArray( - contactToTagChannelModel.contactInboxId, - inboxes.map((inbox) => inbox.id), - ), - ), - ) + await tagService.deleteTagChannelAssignments({ + tagChannelId: tagChannel.id, + contactInboxIds: inboxes.map((inbox) => inbox.id), + }) // Remove the workspace tag from those contacts — same as remove-contact-tag. const contactIds = inboxes.map((inbox) => inbox.contactId) - await db - .delete(contactsToTagsModel) - .where( - and( - eq(contactsToTagsModel.tagId, tagChannel.tagId), - inArray(contactsToTagsModel.contactId, contactIds), - ), - ) + await tagService.detachTagFromContacts({ + tagId: tagChannel.tagId, + contactIds, + }) await emitForContacts( ctx.workspaceId, @@ -188,21 +161,18 @@ async function removeLabel( // ── DB helpers ────────────────────────────────────────── function findInboxes(inboxId: string, sourceIds: string[]) { - return db.query.contactInboxModel.findMany({ - where: { inboxId, sourceId: { in: sourceIds } }, - columns: { id: true, contactId: true }, + return contactInboxRepository.listIdsByInboxAndSourceIds({ + inboxId, + sourceIds, }) } function findTagChannel(ctx: LabelContext, externalLabelId: string) { - return db.query.tagChannelModel.findFirst({ - where: { - workspaceId: ctx.workspaceId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - externalLabelId, - }, - columns: { id: true, tagId: true }, + return tagService.findTagChannel({ + workspaceId: ctx.workspaceId, + channelType: ctx.channelType, + integrationId: ctx.integrationId, + externalLabelId, }) } @@ -220,85 +190,20 @@ async function ensureTagChannel( return // cannot create a tag without a name } - const tagId = await ensureTag(ctx.workspaceId, name) + const tagId = await tagService.ensureTagByName({ + workspaceId: ctx.workspaceId, + name, + }) if (!tagId) { return } - const tagChannelId = await ensureChannel(ctx, tagId, externalLabelId) - return tagChannelId ? { tagId, tagChannelId } : undefined -} - -async function ensureTag( - workspaceId: string, - name: string, -): Promise { - const where = { workspaceId, name, deletedAt: { isNull: true as const } } - - const found = await db.query.tagModel.findFirst({ - where, - columns: { id: true }, - }) - if (found) { - return found.id - } - - const [created] = await db - .insert(tagModel) - .values({ id: createId(), workspaceId, name }) - .onConflictDoNothing({ - // Tag_workspaceId_name_key is a partial unique index (deletedAt IS NULL). - target: [tagModel.workspaceId, tagModel.name], - where: isNull(tagModel.deletedAt), - }) - .returning({ id: tagModel.id }) - if (created) { - return created.id - } - - // Lost a race against a concurrent insert — read the winner back. - const retry = await db.query.tagModel.findFirst({ - where, - columns: { id: true }, - }) - return retry?.id -} - -async function ensureChannel( - ctx: LabelContext, - tagId: string, - externalLabelId: string, -): Promise { - const [created] = await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId: ctx.workspaceId, - tagId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - externalLabelId, - }) - .onConflictDoNothing({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - }) - .returning({ id: tagChannelModel.id }) - if (created) { - return created.id - } - - const retry = await db.query.tagChannelModel.findFirst({ - where: { - tagId, - workspaceId: ctx.workspaceId, - channelType: ctx.channelType, - integrationId: ctx.integrationId, - }, - columns: { id: true }, + const tagChannelId = await tagService.ensureTagChannel({ + workspaceId: ctx.workspaceId, + tagId, + channelType: ctx.channelType, + integrationId: ctx.integrationId, + externalLabelId, }) - return retry?.id + return tagChannelId ? { tagId, tagChannelId } : undefined } diff --git a/apps/worker/src/integration/handlers/message-status.ts b/apps/worker/src/integration/handlers/message-status.ts index e26444eeed..324e880ce0 100644 --- a/apps/worker/src/integration/handlers/message-status.ts +++ b/apps/worker/src/integration/handlers/message-status.ts @@ -1,7 +1,7 @@ import { buildContext, conversationService } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" import { + contactInboxRepository, createMessageRepository, getSafeSinceTime, } from "@chatbotx.io/database/repositories" @@ -31,13 +31,7 @@ type StatusContactInboxWhere = { inboxId: string } & ( ) const findStatusContactInbox = (where: StatusContactInboxWhere) => - db.query.contactInboxModel.findFirst({ - where, - with: { - conversation: true, - contact: true, - }, - }) + contactInboxRepository.findWithConversationAndContact({ where }) /** * Resolves the ContactInbox a delivery/read status belongs to. The status diff --git a/apps/worker/src/integration/handlers/messenger-template-handler.ts b/apps/worker/src/integration/handlers/messenger-template-handler.ts index 3dc74eaebb..fb6b0e9d66 100644 --- a/apps/worker/src/integration/handlers/messenger-template-handler.ts +++ b/apps/worker/src/integration/handlers/messenger-template-handler.ts @@ -1,4 +1,8 @@ -import { db } from "@chatbotx.io/database/client" +import { + inboxService, + messengerIntegrationService, +} from "@chatbotx.io/business" +import type { MessengerMessageTemplateModel } from "@chatbotx.io/database/types" import type { MessengerTemplateParams } from "@chatbotx.io/flow-config" import { contactVariableService, @@ -45,19 +49,8 @@ export async function replaceMessengerTemplateVariables(props: { return replacedParams } -// `typeof db.query.inboxModel.findFirst` alone (no call) resolves to the -// no-`with` overload, which drops `integrationMessenger` from the inferred -// return type — wrapping the actual call (with its `with` config) in a -// function lets `ReturnType` capture the relation instead. -function queryInboxWithIntegrationMessenger(inboxId: string) { - return db.query.inboxModel.findFirst({ - where: { id: inboxId }, - with: { integrationMessenger: true }, - }) -} - type InboxWithIntegrationMessenger = NonNullable< - Awaited> + Awaited> > export type ValidatedMessengerTemplate = { @@ -71,9 +64,7 @@ export type ValidatedMessengerTemplate = { InboxWithIntegrationMessenger["integrationMessenger"] > } - template: NonNullable< - Awaited> - > + template: MessengerMessageTemplateModel } // Accepts templateId string — returns fetched entities so caller avoids re-querying. @@ -82,19 +73,19 @@ export async function validateMessengerTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await queryInboxWithIntegrationMessenger(inboxId) + const inbox = await inboxService.findWithIntegrationMessengerById({ + id: inboxId, + }) if (!inbox?.integrationMessenger) { return null } - const template = await db.query.messengerMessageTemplateModel.findFirst({ - where: { + const template = + await messengerIntegrationService.findApprovedMessengerTemplate({ id: templateId, integrationMessengerId: inbox.integrationMessenger.id, - status: "APPROVED", - }, - }) + }) if (!template) { return null diff --git a/apps/worker/src/integration/handlers/received-message.ts b/apps/worker/src/integration/handlers/received-message.ts index 102c2543c4..cee3678bbe 100644 --- a/apps/worker/src/integration/handlers/received-message.ts +++ b/apps/worker/src/integration/handlers/received-message.ts @@ -20,20 +20,22 @@ import { finalizeContactProfile, normalizeLanguage, } from "@chatbotx.io/business/contact-locale" -import { db, eq, isUniqueViolationError } from "@chatbotx.io/database/client" +import { isUniqueViolationError } from "@chatbotx.io/database/client" import { type ChannelType, type ContactSource, contactSources, type IntegrationType, } from "@chatbotx.io/database/partials" -import { createMessageRepository } from "@chatbotx.io/database/repositories" +import { + contactInboxRepository, + createMessageRepository, +} from "@chatbotx.io/database/repositories" import { CONTACT_INBOX_SOURCE_ID_KEY, CONTACT_INBOX_SOURCE_USER_ID_KEY, contactInboxModel, contactModel, - conversationModel, } from "@chatbotx.io/database/schema" import type { ContactInboxModel, @@ -876,32 +878,17 @@ const persistNewMessageSideEffects = async (props: { contactLocation, } = props - const trackingInvalidation = await db.transaction(async (tx) => { - const invalidation = await contactInboxService.updateTracking({ - tx, - contactInboxId: contactInbox.id, - contactId: contactInbox.contactId, - workspaceId: inbox.workspaceId, - data: { - ...getMessageActivityTracking({ incomingMessage, message, storageUrl }), - ...contactInboxTracking, - }, - }) - - if (contactLocation) { - await contactService.update( - { workspaceId: inbox.workspaceId, id: contactInbox.contactId }, - { location: contactLocation }, - tx, - ) - } - - await tx - .update(conversationModel) - .set({ lastActivityAt: message.createdAt }) - .where(eq(conversationModel.id, conversation.id)) - - return invalidation + const trackingInvalidation = await conversationService.recordInboundActivity({ + workspaceId: inbox.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + contactId: contactInbox.contactId, + tracking: { + ...getMessageActivityTracking({ incomingMessage, message, storageUrl }), + ...contactInboxTracking, + }, + contactLocation, + at: message.createdAt, }) if (trackingInvalidation) { @@ -1209,9 +1196,8 @@ const resolveExistingContactInbox = async ({ incomingContact, }: ContactInboxResolverProps): Promise => await resolveWithSourceUserIdFallback(incomingContact, (where) => - db.query.contactInboxModel.findFirst({ + contactInboxRepository.findWithContact({ where: { inboxId: inbox.id, channel: inbox.channel, ...where }, - with: { contact: true }, }), ) diff --git a/apps/worker/src/integration/handlers/ref.ts b/apps/worker/src/integration/handlers/ref.ts index 333a2d6b8c..7cd5620be7 100644 --- a/apps/worker/src/integration/handlers/ref.ts +++ b/apps/worker/src/integration/handlers/ref.ts @@ -315,58 +315,4 @@ async function handleReflink(props: { contactInboxId: contactInbox.id, }) } - - // Support additional custom fields - - // // Trying to find reflink by custom field - // const refParts = ref.split("--").map((part) => part.trim()) - // if (!refParts[0]) { - // logger.warn(`Invalid ref: ${ref}`) - // return - // } - - // // Save data from custom field - // let customFieldId: string | null = null - // let customField: CustomFieldModel | null | undefined = null - // for (let i = 0; i < refParts.length; i++) { - // if (i === 0) { - // customFieldId = reflink.customFieldId ?? "" - // } else if (i % 2 === 0) { - // customFieldId = refParts[i] - // } - - // if (i % 2 === 0) { - // if (customFieldId) { - // customField = await db.query.customFieldModel.findFirst({ - // where: { - // workspaceId: conversation.workspaceId, - // id: customFieldId, - // }, - // }) - // } else { - // customField = null - // } - // } - - // // Trying to find custom field by name, then update contact custom field - // if (i % 2 === 1 && refParts[i] && customField) { - // await db - // .insert(contactCustomFieldModel) - // .values({ - // id: createId(), - // contactId: conversation.contactId, - // customFieldId: customField.id, - // value: refParts[i], - // }) - // .onConflictDoUpdate({ - // target: [ - // contactCustomFieldModel.contactId, - // contactCustomFieldModel.customFieldId, - // ], - // set: { - // value: refParts[i], - // }, - // }) - // } - // } } diff --git a/apps/worker/src/integration/handlers/send-flow-direct.ts b/apps/worker/src/integration/handlers/send-flow-direct.ts index 4f11331429..3712d0065e 100644 --- a/apps/worker/src/integration/handlers/send-flow-direct.ts +++ b/apps/worker/src/integration/handlers/send-flow-direct.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { contactInboxService, conversationService } from "@chatbotx.io/business" import type { MetadataPayload } from "@chatbotx.io/flow-config" import { runFlowNode } from "./flow" @@ -15,21 +15,17 @@ export async function sendFlowDirect( ): Promise { const { flowExecutionKey, flowId, workspaceId, contactId, metadata } = params - const conversation = await db.query.conversationModel.findFirst({ - where: { - contactId, - workspaceId, - }, + const conversation = await conversationService.findBy({ + where: { contactId, workspaceId }, }) if (!conversation) { throw new Error(`Conversation not found for contact ${contactId}`) } - const allContactInboxes = await db.query.contactInboxModel.findMany({ - where: { - contactId, - }, + const allContactInboxes = await contactInboxService.listByContactId({ + workspaceId, + contactId, }) await Promise.all( diff --git a/apps/worker/src/integration/handlers/sequence-flow.ts b/apps/worker/src/integration/handlers/sequence-flow.ts index b27a05c7d5..a6bde80681 100644 --- a/apps/worker/src/integration/handlers/sequence-flow.ts +++ b/apps/worker/src/integration/handlers/sequence-flow.ts @@ -1,5 +1,4 @@ -import { and, db, eq } from "@chatbotx.io/database/client" -import { sequenceDispatchModel } from "@chatbotx.io/database/schema" +import { contactSequenceService } from "@chatbotx.io/business/contact-sequence" import { sequenceConnections } from "@chatbotx.io/redis" import { SchedulerClient } from "@chatbotx.io/scheduler" import { advanceEnrollment } from "@chatbotx.io/sequence-scheduler" @@ -24,12 +23,9 @@ async function getSchedulerClient(): Promise { } async function fetchDispatch(dispatchId: string, workspaceId: string) { - return await db.query.sequenceDispatchModel.findFirst({ - where: { - id: dispatchId, - workspaceId, - status: "running", - }, + return await contactSequenceService.findRunningDispatch({ + dispatchId, + workspaceId, }) } @@ -38,20 +34,11 @@ async function markDispatchCompleted( workspaceId: string, sentAt: Date, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "completed", - completedAt: sentAt, - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchCompleted({ + dispatchId, + workspaceId, + sentAt, + }) } async function markDispatchCanceled( @@ -59,20 +46,11 @@ async function markDispatchCanceled( workspaceId: string, reason: string, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "canceled", - lastError: reason, - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchCanceled({ + dispatchId, + workspaceId, + reason, + }) } async function markDispatchFailed( @@ -80,21 +58,11 @@ async function markDispatchFailed( workspaceId: string, errorMessage: string, ): Promise { - await db - .update(sequenceDispatchModel) - .set({ - status: "failed", - lastError: errorMessage, - failedAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatchId), - eq(sequenceDispatchModel.workspaceId, workspaceId), - eq(sequenceDispatchModel.status, "running"), - ), - ) + await contactSequenceService.markDispatchFailed({ + dispatchId, + workspaceId, + errorMessage, + }) } async function runSendSequenceFlow( diff --git a/apps/worker/src/integration/handlers/step-handlers.ts b/apps/worker/src/integration/handlers/step-handlers.ts index 32738359d0..189c6d5bf6 100644 --- a/apps/worker/src/integration/handlers/step-handlers.ts +++ b/apps/worker/src/integration/handlers/step-handlers.ts @@ -1,15 +1,12 @@ -import { conversationService } from "@chatbotx.io/business" import { - and, - db, - eq, - gte, - inArray, - or, - type SQL, - sql, -} from "@chatbotx.io/database/client" -import { contactModel, conversationModel } from "@chatbotx.io/database/schema" + contactInboxService, + contactService, + conversationService, + inboxTeamService, + workspaceMemberService, +} from "@chatbotx.io/business" +import { gte, type SQL } from "@chatbotx.io/database/client" +import { conversationModel } from "@chatbotx.io/database/schema" import { type ArchiveConversationStepSchema, type AssignConversationStepSchema, @@ -35,12 +32,13 @@ import type { ExecuteStepResult } from "./step" export async function stepBlockContact({ conversation, }: ExecuteStepProps) { - await db - .update(contactModel) - .set({ - blockedAt: new Date(), - }) - .where(eq(contactModel.id, conversation.contactId)) + // `block` → `update` adds `findByIdOrFail` + `emitContactInfoChangeEvents` + + // cache invalidation the raw write lacked — a deliberate behavior change; + // called out in the PR body. + await contactService.block({ + workspaceId: conversation.workspaceId, + id: conversation.contactId, + }) } export async function stepArchiveConversation({ @@ -82,24 +80,20 @@ export async function stepAssignConversation({ if (step.assignedId.startsWith("u_")) { const userId = step.assignedId.slice(2) - const workspaceMember = await db.query.workspaceMemberModel.findFirst({ - where: { - userId, - workspaceId: conversation.workspaceId, - }, + const isMember = await workspaceMemberService.isMember({ + workspaceId: conversation.workspaceId, + userId, }) - if (workspaceMember) { + if (isMember) { assignedUserId = userId } } else if (step.assignedId.startsWith("t_")) { const inboxTeamId = step.assignedId.slice(2) - const inboxTeam = await db.query.inboxTeamModel.findFirst({ - where: { - id: inboxTeamId, - workspaceId: conversation.workspaceId, - }, + const teamExists = await inboxTeamService.exists({ + workspaceId: conversation.workspaceId, + id: inboxTeamId, }) - if (inboxTeam) { + if (teamExists) { assignedInboxTeamId = inboxTeamId } } @@ -178,16 +172,9 @@ export async function stepAutoAssignConversation({ let requiredUsers: { userId: string }[] = [] if (userIds.length > 0) { - requiredUsers = await db.query.workspaceMemberModel.findMany({ - where: { - workspaceId: conversation.workspaceId, - userId: { - in: userIds, - }, - }, - columns: { - userId: true, - }, + requiredUsers = await workspaceMemberService.listExistingUserIds({ + workspaceId: conversation.workspaceId, + userIds, }) for (const u of requiredUsers) { allocation[`u_${u.userId}`] = { @@ -200,16 +187,9 @@ export async function stepAutoAssignConversation({ let requiredInboxTeams: { id: string }[] = [] if (inboxTeamIds.length > 0) { - requiredInboxTeams = await db.query.inboxTeamModel.findMany({ - where: { - workspaceId: conversation.workspaceId, - id: { - in: inboxTeamIds, - }, - }, - columns: { - id: true, - }, + requiredInboxTeams = await inboxTeamService.listExistingIds({ + workspaceId: conversation.workspaceId, + ids: inboxTeamIds, }) for (const t of requiredInboxTeams) { allocation[`t_${t.id}`] = { @@ -228,34 +208,11 @@ export async function stepAutoAssignConversation({ } } - const conversationCount = await db - .select({ - assignedUserId: conversationModel.assignedUserId, - assignedInboxTeamId: conversationModel.assignedInboxTeamId, - conversationsCount: sql`cast(count(${conversationModel.id}) as int)`, - }) - .from(conversationModel) - .groupBy( - conversationModel.assignedUserId, - conversationModel.assignedInboxTeamId, - ) - .where( - and( - ...filterConversationConditions, - and( - or( - inArray( - conversationModel.assignedUserId, - requiredUsers.map((r) => r.userId), - ), - inArray( - conversationModel.assignedInboxTeamId, - requiredInboxTeams.map((r) => r.id), - ), - ), - ), - ), - ) + const conversationCount = await conversationService.countByAssignee({ + filterConditions: filterConversationConditions, + userIds: requiredUsers.map((r) => r.userId), + inboxTeamIds: requiredInboxTeams.map((r) => r.id), + }) for (const cc of conversationCount) { if (cc.assignedUserId && allocation[`u_${cc.assignedUserId}`]) { allocation[`u_${cc.assignedUserId}`].count = cc.conversationsCount @@ -373,13 +330,9 @@ export const stepSendTyping = async ( const contactInbox = baseContactInbox || - (await db.query.contactInboxModel.findFirst({ - where: { - contactId: conversation.contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId: conversation.workspaceId, + contactId: conversation.contactId, })) if (!contactInbox) { diff --git a/apps/worker/src/integration/handlers/tool-handler.ts b/apps/worker/src/integration/handlers/tool-handler.ts index c8da7e9cca..b7274940ca 100644 --- a/apps/worker/src/integration/handlers/tool-handler.ts +++ b/apps/worker/src/integration/handlers/tool-handler.ts @@ -6,7 +6,6 @@ import { } from "@chatbotx.io/business" import { createSourceTimezoneResolver } from "@chatbotx.io/business/contact-custom-field" import { javascriptExecutionService } from "@chatbotx.io/business/javascript-execution" -import { db } from "@chatbotx.io/database/client" import { type CustomFieldType, type SystemFieldType, @@ -358,16 +357,9 @@ export async function getDataFromJSON({ // Custom-field outputs: unchanged behavior — batched existence lookup then // a single `setValues` call (transactional persistence + change events). if (customFieldMapping.length > 0) { - const validCustomFields = await db.query.customFieldModel.findMany({ - where: { - workspaceId, - id: { - in: customFieldMapping.map((entry) => entry.outputFieldId), - }, - }, - columns: { - id: true, - }, + const validCustomFields = await customFieldService.findManyByIds({ + workspaceId, + ids: customFieldMapping.map((entry) => entry.outputFieldId), }) const validCustomFieldIds = new Set(validCustomFields.map((v) => v.id)) diff --git a/apps/worker/src/integration/handlers/wa-template-handler.ts b/apps/worker/src/integration/handlers/wa-template-handler.ts index 587a84deeb..5701f32a54 100644 --- a/apps/worker/src/integration/handlers/wa-template-handler.ts +++ b/apps/worker/src/integration/handlers/wa-template-handler.ts @@ -1,5 +1,8 @@ -import { db } from "@chatbotx.io/database/client" -import type { IntegrationWhatsappModel } from "@chatbotx.io/database/types" +import { inboxService, integrationWhatsappService } from "@chatbotx.io/business" +import type { + IntegrationWhatsappModel, + WhatsappMessageTemplateModel, +} from "@chatbotx.io/database/types" import { extractTemplateParams, type SendWaTemplateMessageStepSchema, @@ -75,35 +78,30 @@ export async function replaceWhatsappTemplateVariables(props: { export type ValidatedWhatsappTemplate = { inbox: NonNullable< - Awaited> + Awaited> > & { integrationWhatsapp: IntegrationWhatsappModel } - template: NonNullable< - Awaited> - > + template: WhatsappMessageTemplateModel } export async function validateWhatsappTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await db.query.inboxModel.findFirst({ - where: { id: inboxId }, - with: { integrationWhatsapp: true }, + const inbox = await inboxService.findWithIntegrationWhatsappById({ + id: inboxId, }) if (!inbox?.integrationWhatsapp) { return null } - const template = await db.query.whatsappMessageTemplateModel.findFirst({ - where: { + const template = + await integrationWhatsappService.findApprovedWhatsappTemplate({ id: templateId, integrationWhatsappId: inbox.integrationWhatsapp.id, - status: "APPROVED", - }, - }) + }) if (!template) { return null diff --git a/apps/worker/src/integration/utils/contact.ts b/apps/worker/src/integration/utils/contact.ts index c968f2d751..d325633abb 100644 --- a/apps/worker/src/integration/utils/contact.ts +++ b/apps/worker/src/integration/utils/contact.ts @@ -1,6 +1,8 @@ -import { contactCustomFieldService } from "@chatbotx.io/business" +import { + contactCustomFieldService, + contactInboxService, +} from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db } from "@chatbotx.io/database/client" import type { ContactInboxModel } from "@chatbotx.io/database/types" import { getStoragePrefix, uploader } from "@chatbotx.io/filesystem" import { logger } from "../../lib/logger" @@ -15,13 +17,9 @@ export async function getIntegrationContext(props: { const contactInbox = baseContactInbox || - (await db.query.contactInboxModel.findFirst({ - where: { - contactId, - }, - orderBy: { - lastMessageAt: "desc", - }, + (await contactInboxService.findRecentByContactId({ + workspaceId, + contactId, })) if (!contactInbox) { @@ -46,14 +44,7 @@ export async function readCustomFieldValue(props: { }): Promise { const { contactId, customFieldId } = props - const existing = await db.query.contactCustomFieldModel.findFirst({ - where: { - contactId, - customFieldId, - }, - }) - - return existing?.value ?? null + return await contactCustomFieldService.findValue({ contactId, customFieldId }) } export async function saveResultToCustomField(props: { diff --git a/apps/worker/src/lib/db.ts b/apps/worker/src/lib/db.ts index e90767d98d..4e7af209ad 100644 --- a/apps/worker/src/lib/db.ts +++ b/apps/worker/src/lib/db.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { flowService, flowVersionService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { contactInboxModel, conversationModel, @@ -56,26 +57,19 @@ export async function detectFlowVersion(props: { }> { let flowVersion: FlowVersionModel | null | undefined = null if (props.flowVersionId) { - flowVersion = await db.query.flowVersionModel.findFirst({ - where: { - id: props.flowVersionId, - workspaceId: props.workspaceId, - }, + flowVersion = await flowVersionService.findByIdForWorkspace({ + versionId: props.flowVersionId, + workspaceId: props.workspaceId, }) } else if (props.flowId) { - const flow = await db.query.flowModel.findFirst({ - where: { - id: props.flowId, - workspaceId: props.workspaceId, - active: true, - }, + const flow = await flowService.findActiveById({ + id: props.flowId, + workspaceId: props.workspaceId, }) if (flow?.currentVersionId) { - flowVersion = await db.query.flowVersionModel.findFirst({ - where: { - id: flow.currentVersionId, - workspaceId: props.workspaceId, - }, + flowVersion = await flowVersionService.findByIdForWorkspace({ + versionId: flow.currentVersionId, + workspaceId: props.workspaceId, }) } } diff --git a/apps/worker/src/lib/resolve-workspace-id.ts b/apps/worker/src/lib/resolve-workspace-id.ts index f88bb3d781..43ece38552 100644 --- a/apps/worker/src/lib/resolve-workspace-id.ts +++ b/apps/worker/src/lib/resolve-workspace-id.ts @@ -1,8 +1,10 @@ import { conversationService } from "@chatbotx.io/business" import { smartDelayService } from "@chatbotx.io/business/smart-delay" -import { db } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" -import { createAiWorkspaceScopeRepository } from "@chatbotx.io/database/repositories" +import { + createAiWorkspaceScopeRepository, + importRepository, +} from "@chatbotx.io/database/repositories" import { integrationService } from "../services/integrations" const aiWorkspaceScopeRepository = createAiWorkspaceScopeRepository() @@ -94,13 +96,7 @@ const recordIdResolvers: readonly RecordIdResolver[] = [ }, { field: "importId", - resolve: async (id) => - ( - await db.query.importModel.findFirst({ - where: { id }, - columns: { workspaceId: true }, - }) - )?.workspaceId, + resolve: (id) => importRepository.findWorkspaceId({ id }), }, ] diff --git a/apps/worker/src/services/integrations.ts b/apps/worker/src/services/integrations.ts index 47011f2ae0..8b585ea791 100644 --- a/apps/worker/src/services/integrations.ts +++ b/apps/worker/src/services/integrations.ts @@ -3,8 +3,9 @@ import { type IntegrationContext, workspaceService, } from "@chatbotx.io/business" -import { db, findOrFail, sql } from "@chatbotx.io/database/client" +import { findOrFail } from "@chatbotx.io/database/client" import type { IntegrationType } from "@chatbotx.io/database/partials" +import { integrationLookupRepository } from "@chatbotx.io/database/repositories" import { inboxModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, @@ -131,31 +132,30 @@ export const integrationService = { throw new Error(`Unsupported integration: ${integrationType}`) } - const result = await db.execute<{ - id: string - auth: AuthValue - workspaceId: string - inboxId: string - }>( - sql`SELECT * FROM ${sql.identifier(modelName)} WHERE ${sql.identifier(columnName)} = ${integrationIdentifier} LIMIT 1`, - ) + const row = await integrationLookupRepository.findAuthByIdentifier({ + modelName, + columnName, + identifier: integrationIdentifier, + }) - if (!result.rows[0]) { + if (!row) { throw new IntegrationNotFoundError(integrationType, integrationIdentifier) } + const integrationRow = row as IntegrationRow & { workspaceId: string } + const workspace = await workspaceService.findById({ - id: result.rows[0].workspaceId, + id: integrationRow.workspaceId, }) const inbox = await findOrFail({ table: inboxModel, - where: { id: result.rows[0].inboxId }, + where: { id: integrationRow.inboxId }, message: "Inbox not found", }) return { - integrationRow: result.rows[0], + integrationRow, workspace, inbox, } @@ -201,15 +201,12 @@ export const integrationService = { ) } - const result = await db.execute<{ - id: string - auth: AuthValue - inboxId: string - }>( - sql`SELECT * FROM ${sql.identifier(integrationTable)} WHERE "inboxId" = ${contactInbox.inboxId} LIMIT 1`, - ) + const row = await integrationLookupRepository.findAuthByInboxId({ + modelName: integrationTable, + inboxId: contactInbox.inboxId, + }) - if (!result.rows[0]) { + if (!row) { throw new ChannelError( `Unable to find integration auth for channel: ${contactInbox.channel}`, ChannelErrorCategory.AUTH_FAILED, @@ -217,7 +214,7 @@ export const integrationService = { ) } - return result.rows[0] + return row as IntegrationRow }, } diff --git a/packages/business/__tests__/coexist-import-service.test.ts b/packages/business/__tests__/coexist-import-service.test.ts new file mode 100644 index 0000000000..c35ee2b8de --- /dev/null +++ b/packages/business/__tests__/coexist-import-service.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// `coexistImportService.resolveOrCreateContactLinks` is the Coexist historical +// import's phase-1 transaction, moved verbatim out of the worker handler +// (`apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts`) +// when direct `db.*` access was removed from `apps/worker`. +// +// The worker-level test can no longer see inside the transaction, so the +// invariants that used to be asserted there live here now: +// +// * The ContactInbox insert's `onConflictDoNothing()` stays UNTARGETED. A +// concurrent import can win EITHER identity index — (inboxId, sourceId) or +// the partial (inboxId, sourceUserId) — and a targeted clause would let the +// second one abort the whole batch. +// * Rows that lost the insert race are re-SELECTed by sourceId, and rows +// skipped on the partial scoped-user-id index are resolved through the +// winner owning that scoped id and then ALIASED back to the raced entry's +// own import key (message import is keyed by the entry's sourceId). +// * Pre-allocated Contact rows for raced entries are deleted, and +// `importedContacts` counts only the truly-new rows — not the raced ones. +// * Orphan conversations (existing ContactInbox + Contact, missing +// Conversation) are healed so no caller ever receives an empty +// conversationId. +// +// The schema module is stubbed with plain objects — importing the real schema +// opens a database connection through the sharding client. +// --------------------------------------------------------------------------- + +const { mockTransaction, mockCancelByInboxSource } = vi.hoisted(() => ({ + mockTransaction: vi.fn(), + mockCancelByInboxSource: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ __and: args })), + db: { transaction: mockTransaction }, + eq: vi.fn((left: unknown, right: unknown) => ({ __eq: [left, right] })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + __inArray: [column, values], + })), + or: vi.fn((...args: unknown[]) => ({ __or: args })), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + contactSources: { enum: { inboundMessage: "inboundMessage" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: { + id: "ci.id", + inboxId: "ci.inboxId", + sourceId: "ci.sourceId", + sourceUserId: "ci.sourceUserId", + contactId: "ci.contactId", + }, + contactModel: { id: "contact.id" }, + conversationModel: { id: "conv.id", contactId: "conv.contactId" }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: vi.fn(), + withCache: vi.fn(), +})) + +vi.mock("../src/message-cleanup/service", () => ({ + messageCleanupService: { cancelByInboxSource: mockCancelByInboxSource }, +})) + +let idSeq = 0 +vi.mock("@chatbotx.io/utils", () => ({ + createId: () => { + idSeq += 1 + return `id-${idSeq}` + }, +})) + +const { coexistImportService } = await import("../src/coexist-import/service") + +/** + * A recording transaction stub. `selectResults` is drained in call order — + * the method issues its SELECTs in a fixed sequence, so the queue models the + * database's answers turn by turn. + */ +const buildTx = (selectResults: unknown[][]) => { + const calls = { + conversationInsertValues: [] as unknown[][], + contactInboxInsertValues: [] as unknown[][], + contactInsertValues: [] as unknown[][], + deletedContacts: [] as unknown[], + onConflictDoNothingArgs: [] as unknown[], + } + const queue = [...selectResults] + const inboxReturning = { rows: [] as unknown[] } + + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(queue.shift() ?? [])), + })), + })) + + const insert = vi.fn((model: { id?: string }) => ({ + values: vi.fn((rows: unknown[]) => { + if (model.id === "contact.id") { + calls.contactInsertValues.push(rows) + return Promise.resolve(undefined) + } + if (model.id === "ci.id") { + calls.contactInboxInsertValues.push(rows) + return { + onConflictDoNothing: vi.fn((...args: unknown[]) => { + calls.onConflictDoNothingArgs.push(args) + return { + returning: vi.fn(() => Promise.resolve([...inboxReturning.rows])), + } + }), + } + } + calls.conversationInsertValues.push(rows) + return { + onConflictDoNothing: vi.fn(() => Promise.resolve(undefined)), + } + }), + })) + + const del = vi.fn(() => ({ + where: vi.fn((clause: unknown) => { + calls.deletedContacts.push(clause) + return Promise.resolve(undefined) + }), + })) + + return { + calls, + inboxReturning, + tx: { select, insert, delete: del }, + } +} + +const run = async ( + fixture: ReturnType, + input: Parameters[0], +) => { + mockTransaction.mockImplementation( + async (fn: (tx: unknown) => unknown) => await fn(fixture.tx), + ) + return await coexistImportService.resolveOrCreateContactLinks(input) +} + +beforeEach(() => { + vi.clearAllMocks() + idSeq = 0 + mockCancelByInboxSource.mockResolvedValue(undefined) +}) + +describe("coexistImportService.resolveOrCreateContactLinks", () => { + test("the ContactInbox insert's onConflictDoNothing() stays UNTARGETED", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [{ id: "conv-1", contactId: "id-1" }], // conversations for inserted rows + ]) + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1", firstName: "A" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + expect(fixture.calls.onConflictDoNothingArgs).toHaveLength(1) + // A targeted clause would let a conflict on the OTHER identity index + // abort the whole batch, so the call must carry no arguments at all. + expect(fixture.calls.onConflictDoNothingArgs[0]).toEqual([]) + }) + + test("counts only truly-new rows and deletes the pre-allocated Contact of a raced entry", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [{ id: "ci-won", sourceId: "s2", contactId: "contact-won" }], // race winners by sourceId + [ + { id: "ci-1", contactId: "id-1" }, + { id: "ci-won", contactId: "contact-won" }, + ], // conversations for accepted contacts + ]) + // Only s1 inserted; s2 lost the race. + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([ + ["s1", { sourceId: "s1" }], + ["s2", { sourceId: "s2" }], + ]), + sourceIds: ["s1", "s2"], + sourceUserIds: [], + }) + + // 2 attempted - 1 raced = 1 truly new. + expect(result.importedContacts).toBe(1) + // The Contact row pre-allocated for the raced s2 is deleted. + expect(fixture.calls.deletedContacts).toHaveLength(1) + // Both source ids still resolve to a link, so message import can proceed. + expect(result.contactInboxIds.get("s1")?.contactId).toBe("id-1") + expect(result.contactInboxIds.get("s2")?.contactId).toBe("contact-won") + // The event fan-out covers everything resolved through the insert path. + expect( + result.newContactCreatedEvents.map((e) => e.sourceId).sort(), + ).toEqual(["s1", "s2"]) + }) + + test("aliases a scoped-user-id race winner back to the raced entry's own import key", async () => { + const fixture = buildTx([ + [], // no existing ContactInbox rows + [], // no winner under s-new's own sourceId + // the row that already owns scoped user id "u-9", under a DIFFERENT sourceId + [ + { + id: "ci-owner", + sourceId: "s-owner", + sourceUserId: "u-9", + contactId: "contact-owner", + }, + ], + [{ id: "conv-owner", contactId: "contact-owner" }], // conversations + ]) + fixture.inboxReturning.rows = [] // the insert was skipped entirely + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s-new", { sourceId: "s-new", sourceUserId: "u-9" }]]), + sourceIds: ["s-new"], + sourceUserIds: ["u-9"], + }) + + // Resolved under the winner's own sourceId... + expect(result.contactInboxIds.get("s-owner")).toEqual({ + contactInboxId: "ci-owner", + contactId: "contact-owner", + conversationId: "conv-owner", + }) + // ...AND aliased to the raced entry's import key, so downstream message + // import keyed on "s-new" still finds its contact. + expect(result.contactInboxIds.get("s-new")).toEqual( + result.contactInboxIds.get("s-owner"), + ) + expect(result.importedContacts).toBe(0) + }) + + test("heals an orphan conversation so no link is returned with an empty conversationId", async () => { + const fixture = buildTx([ + // an existing ContactInbox with no Conversation + [ + { + id: "ci-1", + sourceId: "s1", + sourceUserId: null, + contactId: "contact-1", + }, + ], + [], // no conversations for that contact → orphan + [{ id: "conv-healed", contactId: "contact-1" }], // read back after the heal insert + ]) + + const result = await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + expect(fixture.calls.conversationInsertValues).toHaveLength(1) + expect(result.contactInboxIds.get("s1")).toEqual({ + contactInboxId: "ci-1", + contactId: "contact-1", + conversationId: "conv-healed", + }) + // Nothing was newly imported — the row already existed. + expect(result.importedContacts).toBe(0) + expect(result.newContactCreatedEvents).toEqual([]) + }) + + test("cancels pending message cleanup for every resolved inbox identity", async () => { + const fixture = buildTx([[], [{ id: "conv-1", contactId: "id-1" }]]) + fixture.inboxReturning.rows = [ + { id: "ci-1", sourceId: "s1", contactId: "id-1" }, + ] + + await run(fixture, { + workspaceId: "ws-1", + inboxId: "inbox-1", + inboxChannel: "whatsapp", + dedup: new Map([["s1", { sourceId: "s1" }]]), + sourceIds: ["s1"], + sourceUserIds: [], + }) + + // Re-created contacts keep their history. + expect(mockCancelByInboxSource).toHaveBeenCalledWith({ + inboxId: "inbox-1", + sourceIds: ["s1"], + tx: fixture.tx, + }) + }) +}) diff --git a/packages/business/__tests__/contact-service-flow-flags.test.ts b/packages/business/__tests__/contact-service-flow-flags.test.ts new file mode 100644 index 0000000000..1d83aaad48 --- /dev/null +++ b/packages/business/__tests__/contact-service-flow-flags.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// The three conditional/flag writes lifted out of the worker flow-step +// handlers (`apps/worker/src/integration/handlers/contact.ts` and +// `contact/update-avatar.ts`) when direct `db.*` access was removed from +// `apps/worker`. +// +// Two of them fold their eligibility check into the UPDATE's own WHERE +// clause. Those `isNull(...)` predicates are TOCTOU guards, not cosmetics: +// +// * `subscribeBroadcastIfUnsubscribed` must not overwrite an existing +// `broadcastSubscribedAt`, or a re-run of the flow step would reset the +// original subscription timestamp. +// * `setAvatarIfEmpty` must not overwrite an avatar a concurrent profile +// refresh already wrote. +// +// A read-then-write refactor would reintroduce both races, so the tests below +// assert the predicate is present in the WHERE and that no read precedes the +// write. `setFlowFlags` deliberately has NO conditional guard but DOES +// invalidate the contact cache (the raw worker write did not — a deliberate +// fix carried by the refactor). +// +// The schema module is stubbed with plain objects rather than +// `importOriginal`-ed: importing the real schema opens a database connection +// through the sharding client. +// --------------------------------------------------------------------------- + +const { mockDbUpdate } = vi.hoisted(() => ({ + mockDbUpdate: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ __and: args })), + db: { update: mockDbUpdate }, + eq: vi.fn((left: unknown, right: unknown) => ({ __eq: [left, right] })), + findOrFail: vi.fn(), + inArray: vi.fn(), + isNull: vi.fn((column: unknown) => ({ __isNull: column })), + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + __sql: [[...strings], values], + })), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: { id: "ci.id", contactId: "ci.contactId" }, + contactModel: { + id: "contact.id", + workspaceId: "contact.workspaceId", + avatar: "contact.avatar", + broadcastSubscribedAt: "contact.broadcastSubscribedAt", + emailOptIn: "contact.emailOptIn", + emailVerified: "contact.emailVerified", + }, + conversationModel: { id: "conv.id", contactId: "conv.contactId" }, + inboxModel: { id: "inbox.id" }, +})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactWhere: vi.fn(), + contactFilterHasPredicate: vi.fn(), +})) + +vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn() })) +vi.mock("@chatbotx.io/events", () => ({ + emitContactCreated: vi.fn(), + emitContactInfoUpdated: vi.fn(), +})) +vi.mock("@chatbotx.io/filesystem", () => ({ uploadFileFromUrl: vi.fn() })) +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: vi.fn(), + withCache: vi.fn(), +})) +vi.mock("@chatbotx.io/analytics", () => ({ macAnalyticsService: {} })) +vi.mock("../src/quota-enforcement/service", () => ({ + quotaEnforcementService: {}, +})) +vi.mock("../src/user-quota/service", () => ({ userQuotaService: {} })) +vi.mock("../src/workspace/service", () => ({ workspaceService: {} })) +vi.mock("../src/workspace-usage/service", () => ({ workspaceUsageService: {} })) +vi.mock("../src/message-cleanup/service", () => ({ messageCleanupService: {} })) + +const { contactService } = await import("../src/contact/service") +const { isNull: isNullMock } = await import("@chatbotx.io/database/client") + +const buildUpdateClient = () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + return { set, update, where } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("contactService.subscribeBroadcastIfUnsubscribed", () => { + test("keeps the isNull(broadcastSubscribedAt) TOCTOU guard inside the UPDATE's WHERE", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.subscribeBroadcastIfUnsubscribed({ + workspaceId: "ws-1", + contactId: "contact-1", + }) + + expect(isNullMock).toHaveBeenCalledWith("contact.broadcastSubscribedAt") + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + { __isNull: "contact.broadcastSubscribedAt" }, + ], + }) + expect(client.set).toHaveBeenCalledWith({ + broadcastSubscribedAt: expect.any(Date), + }) + }) + + test("is a single conditional write — no read precedes it", async () => { + const client = buildUpdateClient() + const query = { contactModel: { findFirst: vi.fn() } } + const select = vi.fn() + + await contactService.subscribeBroadcastIfUnsubscribed( + { workspaceId: "ws-1", contactId: "contact-1" }, + { update: client.update, select, query } as never, + ) + + expect(client.update).toHaveBeenCalledTimes(1) + expect(select).not.toHaveBeenCalled() + expect(query.contactModel.findFirst).not.toHaveBeenCalled() + }) +}) + +describe("contactService.unsubscribeBroadcast", () => { + test("clears the timestamp unconditionally but stays workspace-scoped", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.unsubscribeBroadcast({ + workspaceId: "ws-1", + contactId: "contact-1", + }) + + expect(client.set).toHaveBeenCalledWith({ broadcastSubscribedAt: null }) + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + ], + }) + // No conditional guard here — unsubscribe is idempotent by definition. + expect(isNullMock).not.toHaveBeenCalled() + }) +}) + +describe("contactService.setAvatarIfEmpty", () => { + test("keeps the isNull(avatar) TOCTOU guard inside the UPDATE's WHERE", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + + await contactService.setAvatarIfEmpty({ + workspaceId: "ws-1", + contactId: "contact-1", + avatar: "https://cdn.example/a.png", + }) + + expect(isNullMock).toHaveBeenCalledWith("contact.avatar") + expect(client.where).toHaveBeenCalledWith({ + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + { __isNull: "contact.avatar" }, + ], + }) + expect(client.set).toHaveBeenCalledWith({ + avatar: "https://cdn.example/a.png", + updatedAt: expect.any(Date), + }) + }) + + test("is a single conditional write — no read precedes it", async () => { + const client = buildUpdateClient() + const query = { contactModel: { findFirst: vi.fn() } } + const select = vi.fn() + + await contactService.setAvatarIfEmpty( + { workspaceId: "ws-1", contactId: "contact-1", avatar: "a.png" }, + { update: client.update, select, query } as never, + ) + + expect(client.update).toHaveBeenCalledTimes(1) + expect(select).not.toHaveBeenCalled() + expect(query.contactModel.findFirst).not.toHaveBeenCalled() + }) +}) + +describe("contactService.setFlowFlags", () => { + test("writes the flags by id and invalidates the contact cache", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + const invalidateSpy = vi + .spyOn(contactService, "invalidate") + .mockResolvedValue(undefined) + + await contactService.setFlowFlags( + { workspaceId: "ws-1", id: "contact-1" }, + { emailVerified: true }, + ) + + expect(client.set).toHaveBeenCalledWith({ emailVerified: true }) + expect(client.where).toHaveBeenCalledWith({ + __eq: ["contact.id", "contact-1"], + }) + // The raw worker write skipped invalidation, leaving a stale cached + // contact behind; routing through the service fixes that. + expect(invalidateSpy).toHaveBeenCalledWith({ + workspaceId: "ws-1", + ids: ["contact-1"], + }) + }) + + test("skips the pre-read and the info-change emit that update() would do", async () => { + const client = buildUpdateClient() + mockDbUpdate.mockImplementation(client.update) + vi.spyOn(contactService, "invalidate").mockResolvedValue(undefined) + const findByIdOrFailSpy = vi.spyOn(contactService, "findByIdOrFail") + + await contactService.setFlowFlags( + { workspaceId: "ws-1", id: "contact-1" }, + { emailOptIn: true }, + ) + + expect(findByIdOrFailSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/conversation-service-consume-challenge.test.ts b/packages/business/__tests__/conversation-service-consume-challenge.test.ts index 97ef2b7981..e52718d400 100644 --- a/packages/business/__tests__/conversation-service-consume-challenge.test.ts +++ b/packages/business/__tests__/conversation-service-consume-challenge.test.ts @@ -25,6 +25,15 @@ vi.mock("@chatbotx.io/redis", () => ({ createRedisConnection: vi.fn(() => ({ on: vi.fn() })), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn(), })) diff --git a/packages/business/__tests__/conversation-service.test.ts b/packages/business/__tests__/conversation-service.test.ts index 60e57a28b1..b82d0f1666 100644 --- a/packages/business/__tests__/conversation-service.test.ts +++ b/packages/business/__tests__/conversation-service.test.ts @@ -48,6 +48,15 @@ vi.mock("@chatbotx.io/redis", () => ({ createRedisConnection: vi.fn(() => ({ on: vi.fn() })), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + const { conversationService } = await import("../src/conversation/service") /** diff --git a/packages/business/src/coexist-import/index.ts b/packages/business/src/coexist-import/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/coexist-import/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/coexist-import/service.ts b/packages/business/src/coexist-import/service.ts new file mode 100644 index 0000000000..ed58211b28 --- /dev/null +++ b/packages/business/src/coexist-import/service.ts @@ -0,0 +1,527 @@ +import { + and, + type DatabaseClient, + db, + eq, + inArray, + or, + type SQL, +} from "@chatbotx.io/database/client" +import { contactSources } from "@chatbotx.io/database/partials" +import { + contactInboxModel, + contactModel, + conversationModel, +} from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" +import { messageCleanupService } from "../message-cleanup/service" + +/** + * WHERE clause matching contact-inbox rows in an inbox by EITHER identity + * column, with the empty-list guard every caller needs (`inArray` must never + * receive an empty array; `or(single)` is a no-op wrapper). Duplicated from + * `contact-inbox/service.ts`'s `buildContactInboxIdentityWhere` — kept local + * here (not imported) because this service must not create a cross-domain + * import cycle with `contact-inbox`; keep the two in sync if either changes. + */ +const buildContactInboxIdentityWhere = (props: { + inboxId: string + sourceIds: string[] + sourceUserIds: string[] +}): SQL | undefined => { + const { inboxId, sourceIds, sourceUserIds } = props + const identityPredicates = [ + ...(sourceIds.length > 0 + ? [inArray(contactInboxModel.sourceId, sourceIds)] + : []), + ...(sourceUserIds.length > 0 + ? [inArray(contactInboxModel.sourceUserId, sourceUserIds)] + : []), + ] + return and(eq(contactInboxModel.inboxId, inboxId), or(...identityPredicates)) +} + +export type ContactImportLink = { + contactInboxId: string + contactId: string + conversationId: string +} + +type ContactInboxIdentityRow = { + id: string + sourceId: string + sourceUserId: string | null + contactId: string +} + +const rowsBySourceUserId = ( + rows: readonly T[], +): Map => + new Map( + rows.flatMap((row) => + row.sourceUserId === null ? [] : [[row.sourceUserId, row] as const], + ), + ) + +/** + * Resolves raced entries whose ContactInbox insert was skipped by the partial + * (inboxId, sourceUserId) unique index: finds the winner row owning each + * entry's scoped user id, keyed by the entry's own import sourceId so the + * caller can alias the import key to the winner's link. + */ +const resolveScopedIdRaceWinners = async (props: { + tx: DatabaseClient + inboxId: string + racedEntries: ReadonlyArray +}): Promise> => { + const { tx, inboxId, racedEntries } = props + if (racedEntries.length === 0) { + return new Map() + } + const winners = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + sourceUserId: contactInboxModel.sourceUserId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray( + contactInboxModel.sourceUserId, + racedEntries.map(([, scopedId]) => scopedId), + ), + ), + ) + const winnerByScopedId = rowsBySourceUserId(winners) + const aliases = new Map() + for (const [entrySourceId, scopedId] of racedEntries) { + const winner = winnerByScopedId.get(scopedId) + if (winner) { + aliases.set(entrySourceId, winner) + } + } + return aliases +} + +export type CoexistDedupContact = { + sourceId: string + phoneNumber?: string + phoneNumberId?: string + firstName?: string + lastName?: string + email?: string + avatar?: string + gender?: string + sourceUserId?: string + sourceUsername?: string +} + +export type ResolveOrCreateContactLinksInput = { + workspaceId: string + inboxId: string + inboxChannel: string + dedup: Map + sourceIds: string[] + sourceUserIds: string[] +} + +export type NewContactCreatedEvent = { + workspaceId: string + contactId: string + contactInboxId: string + sourceId: string + firstName?: string + phoneNumber?: string + email?: string + channel: string + source: string + createdAt: Date +} + +export type ResolveOrCreateContactLinksResult = { + importedContacts: number + contactInboxIds: Map + newContactCreatedEvents: NewContactCreatedEvent[] +} + +class CoexistImportService extends BaseService { + /** + * Phase 1 of Coexist historical sync, moved VERBATIM (word-diffed against + * the pre-refactor worker transaction) from + * `apps/worker/src/integration/handlers/coexist/bulk-historical-import.ts`'s + * `bulkImportContacts` `db.transaction` body: resolve/insert + * ContactInbox + Contact + Conversation for a dedup'd batch, healing orphan + * conversations and reconciling scoped-user-id insert races. The caller + * (`bulkImportContacts`) owns dedup, post-commit event emission, and + * workspace-usage accounting — this method only owns the transaction. + */ + async resolveOrCreateContactLinks( + input: ResolveOrCreateContactLinksInput, + ): Promise { + const { + workspaceId, + inboxId, + inboxChannel, + dedup, + sourceIds, + sourceUserIds, + } = input + + const newContactCreatedEvents: NewContactCreatedEvent[] = [] + let importedContacts = 0 + const contactInboxIds = new Map() + + await db.transaction(async (tx) => { + // 1. Find existing ContactInbox rows — by sourceId or scoped user id. + const existingRows = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + sourceUserId: contactInboxModel.sourceUserId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + buildContactInboxIdentityWhere({ + inboxId, + sourceIds, + sourceUserIds, + }), + ) + + const resolved = new Map() + const existingContactIds = new Set() + + for (const row of existingRows) { + existingContactIds.add(row.contactId) + resolved.set(row.sourceId, { + contactInboxId: row.id, + contactId: row.contactId, + conversationId: "", + }) + } + + const existingBySourceUserId = rowsBySourceUserId(existingRows) + for (const [sourceId, entry] of dedup) { + if (resolved.has(sourceId) || !entry.sourceUserId) { + continue + } + const row = existingBySourceUserId.get(entry.sourceUserId) + if (!row) { + continue + } + existingContactIds.add(row.contactId) + resolved.set(sourceId, { + contactInboxId: row.id, + contactId: row.contactId, + conversationId: "", + }) + } + + // Resolve conversation ids for existing contacts. Heal orphans (existing + // ContactInbox + Contact but missing Conversation) by inserting one now, + // so downstream callers never receive an empty conversationId. + if (existingContactIds.size > 0) { + const conversations = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, [...existingContactIds])) + const convByContact = new Map( + conversations.map((c) => [c.contactId, c.id]), + ) + + const orphanContactIds = [...existingContactIds].filter( + (cid) => !convByContact.has(cid), + ) + if (orphanContactIds.length > 0) { + await tx + .insert(conversationModel) + .values( + orphanContactIds.map((cid) => ({ + id: createId(), + workspaceId, + contactId: cid, + })), + ) + .onConflictDoNothing() + const healed = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, orphanContactIds)) + for (const c of healed) { + convByContact.set(c.contactId, c.id) + } + } + + for (const link of resolved.values()) { + const cid = convByContact.get(link.contactId) + if (cid) { + link.conversationId = cid + } + } + } + + const newEntries = [...dedup.entries()].filter( + ([sourceId]) => !resolved.has(sourceId), + ) + const acceptedNew = newEntries + + // 2. Insert Contact + ContactInbox + Conversation for acceptedNew. + if (acceptedNew.length > 0) { + const contactRows = acceptedNew.map(([, entry]) => ({ + id: createId(), + workspaceId, + firstName: entry.firstName, + lastName: entry.lastName, + email: entry.email, + phoneNumber: entry.phoneNumber, + avatar: entry.avatar, + })) + + await tx.insert(contactModel).values(contactRows) + + const contactInboxRows = acceptedNew.map(([sourceId, entry], i) => ({ + id: createId(), + inboxId, + contactId: contactRows[i]?.id, + originalContactId: contactRows[i]?.id, + source: contactSources.enum.inboundMessage, + sourceId, + sourceUserId: entry.sourceUserId ?? null, + sourceUsername: entry.sourceUsername ?? null, + channel: inboxChannel, + createdAt: new Date(), + updatedAt: new Date(), + })) + + const conversationRows = acceptedNew.map((_entry, i) => ({ + id: createId(), + workspaceId, + contactId: contactRows[i]?.id, + })) + + // Targetless DO NOTHING: a concurrent import can win EITHER identity + // index — (inboxId, sourceId) or the partial (inboxId, sourceUserId) — + // and a targeted clause would let the second one abort the whole batch. + const insertedInboxes = await tx + .insert(contactInboxModel) + .values(contactInboxRows) + .onConflictDoNothing() + .returning({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + contactId: contactInboxModel.contactId, + }) + + const insertedSourceIds = new Set( + insertedInboxes.map((r) => r.sourceId), + ) + + // Race recovery — any acceptedNew sourceId not inserted lost to a + // concurrent insert; re-SELECT winners + delete pre-allocated orphans. + const racedSourceIds = acceptedNew + .map(([sourceId]) => sourceId) + .filter((s) => !insertedSourceIds.has(s)) + + // Maps a raced entry's import key to the winner row that claimed its + // scoped user id under a DIFFERENT sourceId — the final link mapping is + // keyed by row.sourceId, so these aliases are re-keyed at the end. + let scopedWinnerAliases = new Map() + + if (racedSourceIds.length > 0) { + const winners = await tx + .select({ + id: contactInboxModel.id, + sourceId: contactInboxModel.sourceId, + contactId: contactInboxModel.contactId, + }) + .from(contactInboxModel) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray(contactInboxModel.sourceId, racedSourceIds), + ), + ) + for (const w of winners) { + insertedInboxes.push(w) + insertedSourceIds.add(w.sourceId) + } + + // A raced row skipped on the scoped-user-id index has no winner under + // its own sourceId — resolve it through the row owning that scoped id. + scopedWinnerAliases = await resolveScopedIdRaceWinners({ + tx, + inboxId, + racedEntries: racedSourceIds.flatMap((sourceId) => { + if (insertedSourceIds.has(sourceId)) { + return [] + } + const scopedId = dedup.get(sourceId)?.sourceUserId + return scopedId ? [[sourceId, scopedId] as const] : [] + }), + }) + for (const winner of scopedWinnerAliases.values()) { + insertedInboxes.push({ + id: winner.id, + sourceId: winner.sourceId, + contactId: winner.contactId, + }) + } + + const racedSet = new Set(racedSourceIds) + const orphanIds: string[] = [] + for (let i = 0; i < acceptedNew.length; i++) { + const sourceId = acceptedNew[i]?.[0] + const contactId = contactRows[i]?.id + if (sourceId && contactId && racedSet.has(sourceId)) { + orphanIds.push(contactId) + } + } + if (orphanIds.length > 0) { + await tx + .delete(contactModel) + .where(inArray(contactModel.id, orphanIds)) + } + } + + // Re-created contacts keep their history: cancel any pending message + // cleanup recorded when contacts with these inbox identities were deleted. + await messageCleanupService.cancelByInboxSource({ + inboxId, + sourceIds: insertedInboxes.map((r) => r.sourceId), + tx, + }) + + const trulyNew = acceptedNew.length - racedSourceIds.length + importedContacts = trulyNew + + const racedSet2 = new Set(racedSourceIds) + const conversationsToInsert = conversationRows.filter( + (_row, i) => !racedSet2.has(acceptedNew[i]?.[0]), + ) + if (conversationsToInsert.length > 0) { + await tx + .insert(conversationModel) + .values(conversationsToInsert) + .onConflictDoNothing() + } + + // Resolve conversation ids for everything just inserted (or raced). + const acceptedContactIds = insertedInboxes.map((r) => r.contactId) + const newConversations = await tx + .select({ + id: conversationModel.id, + contactId: conversationModel.contactId, + }) + .from(conversationModel) + .where(inArray(conversationModel.contactId, acceptedContactIds)) + const convByContactNew = new Map( + newConversations.map((c) => [c.contactId, c.id]), + ) + + for (const inboxRow of insertedInboxes) { + const convId = convByContactNew.get(inboxRow.contactId) + if (!convId) { + continue + } + resolved.set(inboxRow.sourceId, { + contactInboxId: inboxRow.id, + contactId: inboxRow.contactId, + conversationId: convId, + }) + + const entry = dedup.get(inboxRow.sourceId) + if (entry) { + newContactCreatedEvents.push({ + workspaceId, + contactId: inboxRow.contactId, + contactInboxId: inboxRow.id, + sourceId: inboxRow.sourceId, + firstName: entry.firstName, + phoneNumber: entry.phoneNumber, + email: entry.email, + channel: inboxChannel, + source: contactSources.enum.inboundMessage, + createdAt: new Date(), + }) + } + } + + // Scoped-id winners resolve under their own sourceId above; alias the + // raced entry's import key to the same link so downstream message + // imports keyed by the entry's sourceId still find their contact. + for (const [entrySourceId, winner] of scopedWinnerAliases) { + const link = resolved.get(winner.sourceId) + if (link) { + resolved.set(entrySourceId, link) + } + } + } + + for (const [sourceId, link] of resolved) { + contactInboxIds.set(sourceId, link) + } + }) + + return { importedContacts, contactInboxIds, newContactCreatedEvents } + } + + /** + * One-shot ContactInbox+Conversation resolution by `(inboxId, sourceId[])` + * — moved from `messenger-sync.ts`'s inline `leftJoin` query. Keep the + * `leftJoin` shape verbatim (a missing conversation yields `conversationId: + * null`, filtered out by the caller). + */ + async listContactLinksBySourceIds(props: { + inboxId: string + sourceIds: string[] + }): Promise< + Array<{ + sourceId: string + contactInboxId: string + contactId: string + conversationId: string | null + }> + > { + const { inboxId, sourceIds } = props + if (sourceIds.length === 0) { + return [] + } + const rows = await db + .select({ + sourceId: contactInboxModel.sourceId, + contactInboxId: contactInboxModel.id, + contactId: contactInboxModel.contactId, + conversationId: conversationModel.id, + }) + .from(contactInboxModel) + .leftJoin( + conversationModel, + eq(conversationModel.contactId, contactInboxModel.contactId), + ) + .where( + and( + eq(contactInboxModel.inboxId, inboxId), + inArray(contactInboxModel.sourceId, sourceIds), + ), + ) + return rows.map((row) => ({ + sourceId: row.sourceId ?? "", + contactInboxId: row.contactInboxId, + contactId: row.contactId, + conversationId: row.conversationId, + })) + } +} + +export const coexistImportService = new CoexistImportService() diff --git a/packages/business/src/coexist/service.ts b/packages/business/src/coexist/service.ts index 02572c1dc6..873e7bd002 100644 --- a/packages/business/src/coexist/service.ts +++ b/packages/business/src/coexist/service.ts @@ -307,6 +307,37 @@ class CoexistService extends BaseService { input, ) } + + findLastSyncedAt(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findLastSyncedAt(input) + } + + incrementProgress( + input: Parameters[0], + ): Promise { + return coexistSyncRunRepository.incrementProgress(input) + } + + findInitState(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findInitState(input) + } + + claimRunForSync(input: { + runId: string + touchUpdatedAt: boolean + }): Promise { + return coexistSyncRunRepository.claimRunForSync(input) + } + + findTerminalCounters(input: { + runId: string + }): ReturnType { + return coexistSyncRunRepository.findTerminalCounters(input) + } } export const coexistService = new CoexistService() diff --git a/packages/business/src/contact-sequence/service.ts b/packages/business/src/contact-sequence/service.ts index 8128862a93..c8cd7e01d8 100644 --- a/packages/business/src/contact-sequence/service.ts +++ b/packages/business/src/contact-sequence/service.ts @@ -20,6 +20,7 @@ import { enrollContactInSequence, enrollContactsInSequenceBulk, removeDispatchesFromSchedule, + sequenceDispatchUtils, } from "@chatbotx.io/sequence-scheduler" import { BaseService } from "../base.service" import { type ContactAccessScope, contactService } from "../contact/service" @@ -674,6 +675,80 @@ class ContactSequenceService extends BaseService { ): Promise { return await db.transaction(callback) } + + /** Already-enrolled guard for the "Subscribe to Sequence" flow step. */ + async isEnrolled(props: { + workspaceId: string + contactId: string + sequenceId: string + tx?: DrizzleClient + }): Promise { + const { workspaceId, contactId, sequenceId, tx = db } = props + const existing = await tx.query.contactsOnSequenceModel.findFirst({ + where: { contactId, sequenceId, workspaceId }, + columns: { id: true }, + }) + return Boolean(existing) + } + + /** First active step (order 0) — used to compute `nextRunAt` on enroll. */ + async findFirstActiveStep(props: { + sequenceId: string + tx?: DrizzleClient + }): Promise< + { id: string; delayDays: number; delayMinutes: number } | undefined + > { + const { sequenceId, tx = db } = props + return await tx.query.sequenceStepModel.findFirst({ + where: { sequenceId, order: 0, isActive: true }, + columns: { id: true, delayDays: true, delayMinutes: true }, + }) + } + + /** Sequence name for the `sequenceSubscribed` emit. */ + async findSequenceName(props: { + sequenceId: string + tx?: DrizzleClient + }): Promise { + const { sequenceId, tx = db } = props + const sequence = await tx.query.sequenceModel.findFirst({ + where: { id: sequenceId }, + columns: { name: true }, + }) + return sequence?.name + } + + /** Load a running dispatch for the sequence-flow worker handler. */ + findRunningDispatch(props: { dispatchId: string; workspaceId: string }) { + return sequenceDispatchUtils.findRunning({ dbClient: db, ...props }) + } + + /** Mark a dispatch completed — keeps the `status = 'running'` idempotency guard. */ + markDispatchCompleted(props: { + dispatchId: string + workspaceId: string + sentAt: Date + }): Promise { + return sequenceDispatchUtils.markCompleted({ dbClient: db, ...props }) + } + + /** Mark a dispatch canceled — keeps the `status = 'running'` idempotency guard. */ + markDispatchCanceled(props: { + dispatchId: string + workspaceId: string + reason: string + }): Promise { + return sequenceDispatchUtils.markCanceled({ dbClient: db, ...props }) + } + + /** Mark a dispatch failed — keeps the `status = 'running'` idempotency guard. */ + markDispatchFailed(props: { + dispatchId: string + workspaceId: string + errorMessage: string + }): Promise { + return sequenceDispatchUtils.markFailed({ dbClient: db, ...props }) + } } export const contactSequenceService = new ContactSequenceService() diff --git a/packages/business/src/contact/service.ts b/packages/business/src/contact/service.ts index f96086d432..480d9a19a3 100644 --- a/packages/business/src/contact/service.ts +++ b/packages/business/src/contact/service.ts @@ -891,6 +891,84 @@ class ContactService extends BaseService { .where(eq(contactModel.id, cid)) await invalidateCacheByTags([`contacts:${cid}`]) } + + /** + * Thin flow-step flag write (email verified / opt-in / opt-out). Skips the + * pre-read and `emitContactInfoChangeEvents` that `update()` performs — this + * is a hot flow-step path and those steps never emitted before — but DOES + * invalidate the contact cache, which the raw `db.update` this replaces did + * NOT do. That cache invalidation is a deliberate bug fix; call it out in + * the PR body. Do not route through `update()` (adds `findByIdOrFail` + + * `emitContactInfoChangeEvents` on this hot step). + */ + async setFlowFlags( + ctx: { workspaceId: string; id: string }, + data: Partial>, + tx: DatabaseClient = db, + ): Promise { + await tx.update(contactModel).set(data).where(eq(contactModel.id, ctx.id)) + await this.invalidate({ workspaceId: ctx.workspaceId, ids: [ctx.id] }) + } + + /** + * Conditional broadcast subscribe — the `isNull(broadcastSubscribedAt)` + * predicate is a TOCTOU guard and MUST stay in the WHERE clause (mirrors + * `updateIfProfileNameEmpty`). + */ + async subscribeBroadcastIfUnsubscribed( + props: { workspaceId: string; contactId: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId } = props + await tx + .update(contactModel) + .set({ broadcastSubscribedAt: new Date() }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + isNull(contactModel.broadcastSubscribedAt), + ), + ) + } + + /** Unconditional broadcast unsubscribe. Handler keeps `emitContactUnsubscribed`. */ + async unsubscribeBroadcast( + props: { workspaceId: string; contactId: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId } = props + await tx + .update(contactModel) + .set({ broadcastSubscribedAt: null }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + ), + ) + } + + /** + * Conditional avatar write — keeps `isNull(avatar)` in the WHERE clause (a + * TOCTOU guard, same pattern as `updateIfProfileNameEmpty`). + */ + async setAvatarIfEmpty( + props: { workspaceId: string; contactId: string; avatar: string }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, contactId, avatar } = props + await tx + .update(contactModel) + .set({ avatar, updatedAt: new Date() }) + .where( + and( + eq(contactModel.id, contactId), + eq(contactModel.workspaceId, workspaceId), + isNull(contactModel.avatar), + ), + ) + } } function richSystemFieldToContactData( diff --git a/packages/business/src/conversation/__tests__/conversation.service.test.ts b/packages/business/src/conversation/__tests__/conversation.service.test.ts index 4724baac36..15d7e54ea1 100644 --- a/packages/business/src/conversation/__tests__/conversation.service.test.ts +++ b/packages/business/src/conversation/__tests__/conversation.service.test.ts @@ -18,14 +18,37 @@ vi.mock("@chatbotx.io/database/client", () => ({ sql: vi.fn(), })) +// Plain object stubs only — importing the real schema opens a database +// connection through the sharding client. The extra models come from +// `contactService`, now in `conversationService`'s import chain. vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: {}, + workspaceUsageModel: {}, + userQuotaModel: {}, + questionnaireSubmissionModel: {}, + adsConversionEventModel: {}, + refLinkStatModel: {}, + contactsOnSequenceModel: {}, + contactsOnBroadcastsModel: {}, + contactsToTagsModel: {}, + contactModel: {}, conversationModel: {}, + inboxModel: {}, })) vi.mock("@chatbotx.io/redis", () => ({ withCache: vi.fn(), })) +// `conversationService` now imports `contactService` (for the location write +// inside `recordInboundActivity`), which pulls the analytics package into the +// import chain; its MAC tracking service reads `bloomFilter` off +// `@chatbotx.io/redis` at module scope. Stub analytics rather than partially +// mocking redis — matches the contact-service tests' convention. +vi.mock("@chatbotx.io/analytics", () => ({ + macAnalyticsService: {}, +})) + vi.mock("@chatbotx.io/event-bus", () => ({ emit: vi.fn(), })) diff --git a/packages/business/src/conversation/service.ts b/packages/business/src/conversation/service.ts index 0b81f9f76e..d9de8f1175 100644 --- a/packages/business/src/conversation/service.ts +++ b/packages/business/src/conversation/service.ts @@ -4,6 +4,8 @@ import { db, eq, inArray, + or, + type SQL, sql, } from "@chatbotx.io/database/client" import { @@ -49,6 +51,11 @@ import { notificationQueue, } from "@chatbotx.io/worker-config" import { BaseService } from "../base.service" +import { contactService } from "../contact" +import type { + ContactInboxTrackingData, + ContactInboxTrackingInvalidation, +} from "../contact-inbox/service" import { contactInboxService } from "../contact-inbox/service" import { notFoundException } from "../errors" import { logger } from "../logger" @@ -1149,6 +1156,213 @@ class ConversationService extends BaseService { ] await this.invalidateCacheTags(tags) } + + /** Conversation + contact, for the hot send-flow-step path (do NOT use `findWithFullRelations` here — it fetches far more). */ + async findByIdWithContact(props: { + id: string + tx?: DatabaseClient + }): Promise< + (ConversationModel & { contact: ContactModel | null }) | undefined + > { + const { id, tx = db } = props + return await tx.query.conversationModel.findFirst({ + where: { id }, + with: { contact: true }, + }) + } + + /** + * Inbound-message activity write — owns the transaction: contact-inbox + * tracking update, an optional contact location write, and the + * conversation's `lastActivityAt` advance (via `updateFlowStepState` + * instead of a raw `tx.update`). Moved from + * `received-message.ts`'s `persistNewMessageSideEffects`. + * + * NOTE: `updateFlowStepState`'s WHERE includes `workspaceId` — the raw + * worker version did not scope by `workspaceId` on this UPDATE. Safe here + * because the conversation is already loaded workspace-scoped upstream, + * but this is a deliberate behavior change — call it out in the PR body. + */ + async recordInboundActivity(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + tracking: ContactInboxTrackingData + contactLocation?: ContactModel["location"] | null + at: Date + }): Promise { + const { + workspaceId, + conversationId, + contactInboxId, + contactId, + tracking, + contactLocation, + at, + } = props + + return await db.transaction(async (tx) => { + const invalidation = await contactInboxService.updateTracking({ + tx, + contactInboxId, + contactId, + workspaceId, + data: tracking, + }) + + if (contactLocation) { + await contactService.update( + { workspaceId, id: contactId }, + { location: contactLocation }, + tx, + ) + } + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + }) + + return invalidation + }) + } + + /** + * Outbound flow-step send activity — owns the transaction: + * `recordOutboundMessageCreated` + `updateFlowStepState` (advances + * `currentStep`/`lastStep`/`lastActivityAt`). Moved from + * `chat/handlers/send-flow-step.ts`'s `sendFlowStep`. + */ + async recordOutboundFlowStep(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + at: Date + lastStep?: string | null + currentStep?: string | null + }): Promise { + const { + workspaceId, + conversationId, + contactInboxId, + contactId, + at, + lastStep, + currentStep, + } = props + + return await db.transaction(async (tx) => { + const invalidation = + await contactInboxService.recordOutboundMessageCreated({ + tx, + contactInboxId, + contactId, + workspaceId, + at, + }) + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + lastStep, + currentStep, + }) + + return invalidation + }) + } + + /** + * Outbound message activity (chat / template sends) — owns the + * transaction: `recordOutboundMessageCreated` + `updateFlowStepState` + * (`lastActivityAt` only). Shared by `sendChatMessage`, + * `send-messenger-template.ts`, and `send-whatsapp-template.ts` — write + * once, call from all three. + * + * NOTE: `updateFlowStepState`'s WHERE includes `workspaceId` — the raw + * worker version's `tx.update(conversationModel)` scoped only by `id`. + * Safe here (conversation is already workspace-scoped upstream) but a + * deliberate behavior change — call it out in the PR body. + */ + async recordOutboundMessageActivity(props: { + workspaceId: string + conversationId: string + contactInboxId: string + contactId: string + at: Date + }): Promise { + const { workspaceId, conversationId, contactInboxId, contactId, at } = props + + return await db.transaction(async (tx) => { + const invalidation = + await contactInboxService.recordOutboundMessageCreated({ + tx, + contactInboxId, + contactId, + workspaceId, + at, + }) + + await this.updateFlowStepState({ + tx, + workspaceId, + conversationId, + lastActivityAt: at, + }) + + return invalidation + }) + } + + /** + * Per-assignee open-conversation counts for round-robin allocation + * (`step-handlers.ts`'s `stepAutoAssignConversation`). Accepts the raw + * `filterConditions` `SQL[]` built by the caller (e.g. the "last N hours" + * rule) rather than a semantic filter object — a documented fallback to + * avoid a larger move of `filterConversationConditions` construction. + */ + async countByAssignee(props: { + filterConditions: SQL[] + userIds: string[] + inboxTeamIds: string[] + tx?: DatabaseClient + }): Promise< + Array<{ + assignedUserId: string | null + assignedInboxTeamId: string | null + conversationsCount: number + }> + > { + const { filterConditions, userIds, inboxTeamIds, tx = db } = props + return await tx + .select({ + assignedUserId: conversationModel.assignedUserId, + assignedInboxTeamId: conversationModel.assignedInboxTeamId, + conversationsCount: sql`cast(count(${conversationModel.id}) as int)`, + }) + .from(conversationModel) + .groupBy( + conversationModel.assignedUserId, + conversationModel.assignedInboxTeamId, + ) + .where( + and( + ...filterConditions, + and( + or( + inArray(conversationModel.assignedUserId, userIds), + inArray(conversationModel.assignedInboxTeamId, inboxTeamIds), + ), + ), + ), + ) + } } export const conversationService = new ConversationService() diff --git a/packages/business/src/enterprise/inbox-team/service.ts b/packages/business/src/enterprise/inbox-team/service.ts index b81fc23262..ef12e7e5d9 100644 --- a/packages/business/src/enterprise/inbox-team/service.ts +++ b/packages/business/src/enterprise/inbox-team/service.ts @@ -214,6 +214,36 @@ class InboxTeamService extends BaseService { `inbox-teams:${props.workspaceId}`, ]) } + + /** Validate one assignee team id (flow-step assign). */ + async exists(props: { + workspaceId: string + id: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, tx = db } = props + const team = await tx.query.inboxTeamModel.findFirst({ + where: { id, workspaceId }, + columns: { id: true }, + }) + return Boolean(team) + } + + /** Bulk team validation for round-robin allocation. */ + async listExistingIds(props: { + workspaceId: string + ids: string[] + tx?: DatabaseClient + }): Promise<{ id: string }[]> { + const { workspaceId, ids, tx = db } = props + if (ids.length === 0) { + return [] + } + return await tx.query.inboxTeamModel.findMany({ + where: { workspaceId, id: { in: ids } }, + columns: { id: true }, + }) + } } export const inboxTeamService = new InboxTeamService() diff --git a/packages/business/src/flow-version/service.ts b/packages/business/src/flow-version/service.ts index 48cbef9092..8ed372f94a 100644 --- a/packages/business/src/flow-version/service.ts +++ b/packages/business/src/flow-version/service.ts @@ -285,6 +285,23 @@ class FlowVersionService extends BaseService { async invalidateList(flowId: string): Promise { await this.invalidateCacheTags(`flows:${flowId}:versions`) } + + /** + * Version by explicit id, scoped only to workspace — no `flowId`, no + * `isDraft` filter, and never throws. Matches the worker's + * `detectFlowVersion` exact where-clause; do NOT reuse `findById` (which + * requires `flowId`, forces `isDraft: false`, and throws). + */ + async findByIdForWorkspace(props: { + versionId: string + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { versionId, workspaceId, tx = db } = props + return await tx.query.flowVersionModel.findFirst({ + where: { id: versionId, workspaceId }, + }) + } } export const flowVersionService = new FlowVersionService() diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index a252b5eae2..c10ef08dc2 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -458,6 +458,36 @@ class FlowService extends BaseService { `deleted flow${flows.length > 1 ? "s" : ""} (${flows.map((flow) => `#${flow.id}`).join(", ")})`, ) } + + /** + * Active flow by id, scoped to workspace. Used by worker's + * `detectFlowVersion` to resolve the current version off `currentVersionId`. + */ + async findActiveById(props: { + id: string + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { id, workspaceId, tx = db } = props + return await tx.query.flowModel.findFirst({ + where: { id, workspaceId, active: true }, + }) + } + + /** + * Any active flow in the workspace, with NO ordering — used only as + * button-encoding context (e.g. `send-messenger-template.ts`). The + * "no ordering" behavior is intentional; do not add an `orderBy`. + */ + async findAnyActive(props: { + workspaceId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, tx = db } = props + return await tx.query.flowModel.findFirst({ + where: { workspaceId, active: true }, + }) + } } export const flowService = new FlowService() diff --git a/packages/business/src/inbox/service.ts b/packages/business/src/inbox/service.ts index 60041f8f6e..7112b5562b 100644 --- a/packages/business/src/inbox/service.ts +++ b/packages/business/src/inbox/service.ts @@ -16,6 +16,8 @@ import { inboxModel } from "@chatbotx.io/database/schema" import type { InboxModel, InboxWithIntegrations, + IntegrationMessengerModel, + IntegrationWhatsappModel, } from "@chatbotx.io/database/types" import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" @@ -303,5 +305,41 @@ class InboxService extends BaseService { .limit(1) return !!row } + + /** + * Inbox + `integrationMessenger` relation, with an explicit return type so + * the relation survives inference (a bare `typeof db.query.inboxModel + * .findFirst` with no call resolves to the no-`with` overload and drops + * the relation — see `messenger-template-handler.ts`'s prior local + * workaround). + */ + async findWithIntegrationMessengerById(props: { + id: string + tx?: DatabaseClient + }): Promise< + | (InboxModel & { integrationMessenger: IntegrationMessengerModel | null }) + | undefined + > { + const { id, tx = db } = props + return await tx.query.inboxModel.findFirst({ + where: { id }, + with: { integrationMessenger: true }, + }) + } + + /** Inbox + `integrationWhatsapp` relation — same explicit-return-type reasoning as above. */ + async findWithIntegrationWhatsappById(props: { + id: string + tx?: DatabaseClient + }): Promise< + | (InboxModel & { integrationWhatsapp: IntegrationWhatsappModel | null }) + | undefined + > { + const { id, tx = db } = props + return await tx.query.inboxModel.findFirst({ + where: { id }, + with: { integrationWhatsapp: true }, + }) + } } export const inboxService = new InboxService() diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index b3d224760e..0c1c6f87e8 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -11,6 +11,7 @@ export * from "./automation-throttle" export * from "./bot-field" export * from "./broadcast" export * from "./coexist" +export * from "./coexist-import" export * from "./contact" export * from "./contact-custom-field" export * from "./contact-export" diff --git a/packages/business/src/integration-messenger/service.ts b/packages/business/src/integration-messenger/service.ts index 12bb3810af..320512219e 100644 --- a/packages/business/src/integration-messenger/service.ts +++ b/packages/business/src/integration-messenger/service.ts @@ -12,7 +12,10 @@ import type { } from "@chatbotx.io/database/partials" import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" import { integrationMessengerModel } from "@chatbotx.io/database/schema" -import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" +import type { + IntegrationMessengerModel, + MessengerMessageTemplateModel, +} from "@chatbotx.io/database/types" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { @@ -239,6 +242,38 @@ class MessengerIntegrationService extends BaseService { return rows.length > 0 } + + /** + * Load by id with NO workspace scope — delegates to the repository. + * Callers that separately have a `workspaceId` must compare it themselves + * (see `coexist/messenger-sync.ts`'s explicit-mismatch branch); this must + * NOT be used as a substitute for `findByIdForWorkspace`. + */ + findById(props: { id: string }) { + return integrationMessengerRepository.findById(props) + } + + /** + * Load by Facebook page id with NO workspace scope — for inbound webhooks + * that have not yet resolved a workspace (e.g. inbox-label sync). + */ + findByPageIdUnscoped(props: { pageId: string }) { + return integrationMessengerRepository.findByPageIdUnscoped(props) + } + + /** Approved Messenger message template lookup for outbound template sends. */ + findApprovedMessengerTemplate(props: { + id: string + integrationMessengerId: string + }): Promise { + return db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationMessengerId: props.integrationMessengerId, + status: "APPROVED", + }, + }) + } } export const messengerIntegrationService = new MessengerIntegrationService() diff --git a/packages/business/src/integration-whatsapp/service.ts b/packages/business/src/integration-whatsapp/service.ts index 6903f242fe..7736e3cea2 100644 --- a/packages/business/src/integration-whatsapp/service.ts +++ b/packages/business/src/integration-whatsapp/service.ts @@ -1,4 +1,4 @@ -import type { DatabaseClient } from "@chatbotx.io/database/client" +import { type DatabaseClient, db } from "@chatbotx.io/database/client" import type { WhatsappRegistrationStatus } from "@chatbotx.io/database/partials" import { integrationWhatsappRepository, @@ -7,6 +7,7 @@ import { import type { IntegrationWhatsappRegistrationError } from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, + WhatsappMessageTemplateModel, WhatsappSignupSessionModel, } from "@chatbotx.io/database/types" import { encryptedDataSchema, encryptUtils } from "@chatbotx.io/encryption" @@ -612,6 +613,25 @@ class IntegrationWhatsappService extends BaseService { setCoexist(input: SetCoexistInput): Promise { return setCoexist(input) } + + /** Mark that the operator declined the coexist history-import prompt. */ + markHistoryDeclined(props: { id: string }): Promise { + return integrationWhatsappRepository.markHistoryDeclined(props) + } + + /** Approved WhatsApp message template lookup for outbound template sends. */ + findApprovedWhatsappTemplate(props: { + id: string + integrationWhatsappId: string + }): Promise { + return db.query.whatsappMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationWhatsappId: props.integrationWhatsappId, + status: "APPROVED", + }, + }) + } } export const integrationWhatsappService = new IntegrationWhatsappService() diff --git a/packages/business/src/integration-zalo/service.ts b/packages/business/src/integration-zalo/service.ts index c38cbdd510..cb45011e2a 100644 --- a/packages/business/src/integration-zalo/service.ts +++ b/packages/business/src/integration-zalo/service.ts @@ -65,6 +65,17 @@ class ZaloIntegrationService extends BaseService { .set({ tokenRefreshError: error }) .where(eq(integrationZaloModel.id, id)) } + + /** + * Load a Zalo integration by OA id with NO workspace scope — used by + * inbound webhooks (e.g. inbox-label sync) that only have the OA id and + * have not yet resolved a workspace. + */ + findByOaId(props: { oaId: string }) { + return db.query.integrationZaloModel.findFirst({ + where: { oaId: props.oaId }, + }) + } } export const zaloIntegrationService = new ZaloIntegrationService() diff --git a/packages/business/src/tag/service.ts b/packages/business/src/tag/service.ts index f143d5bc8b..f52cc84b04 100644 --- a/packages/business/src/tag/service.ts +++ b/packages/business/src/tag/service.ts @@ -17,9 +17,10 @@ import { contactModel, contactsToTagsModel, contactToTagChannelModel, + tagChannelModel, tagModel, } from "@chatbotx.io/database/schema" -import type { TagModel } from "@chatbotx.io/database/types" +import type { TagChannelModel, TagModel } from "@chatbotx.io/database/types" import { likeContains, parseOrderByAsObject, @@ -1030,6 +1031,203 @@ class TagService extends BaseService { .catch(() => {}) } } + + /** Unlink one workspace tag from many contacts (inbox-label unassign). */ + async detachTagFromContacts(props: { + tagId: string + contactIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagId, contactIds, tx = db } = props + if (contactIds.length === 0) { + return + } + await tx + .delete(contactsToTagsModel) + .where( + and( + eq(contactsToTagsModel.tagId, tagId), + inArray(contactsToTagsModel.contactId, contactIds), + ), + ) + } + + /** + * Link a workspace tag to many contacts, returning the NEWLY-linked + * contact ids (untargeted `onConflictDoNothing()` — verbatim from + * `inbox_labels/sync.ts` `assignLabel`). + */ + async linkTagToContactsReturningNew(props: { + tagId: string + contactIds: string[] + tx?: DatabaseClient + }): Promise<{ contactId: string }[]> { + const { tagId, contactIds, tx = db } = props + if (contactIds.length === 0) { + return [] + } + return await tx + .insert(contactsToTagsModel) + .values(contactIds.map((contactId) => ({ contactId, tagId }))) + .onConflictDoNothing() + .returning({ contactId: contactsToTagsModel.contactId }) + } + + /** Record per-channel tag assignments (used for reconciliation / detach). */ + async recordTagChannelAssignments(props: { + tagId: string + tagChannelId: string + contactInboxIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagId, tagChannelId, contactInboxIds, tx = db } = props + if (contactInboxIds.length === 0) { + return + } + await tx + .insert(contactToTagChannelModel) + .values( + contactInboxIds.map((contactInboxId) => ({ + tagId, + tagChannelId, + contactInboxId, + })), + ) + .onConflictDoNothing() + } + + /** Remove per-channel tag assignments (inbox-label unassign). */ + async deleteTagChannelAssignments(props: { + tagChannelId: string + contactInboxIds: string[] + tx?: DatabaseClient + }): Promise { + const { tagChannelId, contactInboxIds, tx = db } = props + if (contactInboxIds.length === 0) { + return + } + await tx + .delete(contactToTagChannelModel) + .where( + and( + eq(contactToTagChannelModel.tagChannelId, tagChannelId), + inArray(contactToTagChannelModel.contactInboxId, contactInboxIds), + ), + ) + } + + /** Find the channel mapping for an external label id. */ + async findTagChannel(props: { + workspaceId: string + channelType: TagChannelModel["channelType"] + integrationId: string + externalLabelId: string + tx?: DatabaseClient + }): Promise | undefined> { + const { + workspaceId, + channelType, + integrationId, + externalLabelId, + tx = db, + } = props + return await tx.query.tagChannelModel.findFirst({ + where: { workspaceId, channelType, integrationId, externalLabelId }, + columns: { id: true, tagId: true }, + }) + } + + /** + * Get-or-create a tag by name — moved VERBATIM from `inbox_labels/sync.ts` + * `ensureTag`, including the three-step race handling (find → insert with + * the partial-unique `onConflictDoNothing` → read-back retry on a lost + * race). Do not simplify. + */ + async ensureTagByName(props: { + workspaceId: string + name: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, name, tx = db } = props + const where = { workspaceId, name, deletedAt: { isNull: true as const } } + + const found = await tx.query.tagModel.findFirst({ + where, + columns: { id: true }, + }) + if (found) { + return found.id + } + + const [created] = await tx + .insert(tagModel) + .values({ id: createId(), workspaceId, name }) + .onConflictDoNothing({ + // Tag_workspaceId_name_key is a partial unique index (deletedAt IS NULL). + target: [tagModel.workspaceId, tagModel.name], + where: isNull(tagModel.deletedAt), + }) + .returning({ id: tagModel.id }) + if (created) { + return created.id + } + + // Lost a race against a concurrent insert — read the winner back. + const retry = await tx.query.tagModel.findFirst({ + where, + columns: { id: true }, + }) + return retry?.id + } + + /** + * Get-or-create a tag's channel mapping — moved VERBATIM from + * `inbox_labels/sync.ts` `ensureChannel`, including the read-back retry. + */ + async ensureTagChannel(props: { + workspaceId: string + tagId: string + channelType: TagChannelModel["channelType"] + integrationId: string + externalLabelId: string + tx?: DatabaseClient + }): Promise { + const { + workspaceId, + tagId, + channelType, + integrationId, + externalLabelId, + tx = db, + } = props + const [created] = await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId, + tagId, + channelType, + integrationId, + externalLabelId, + }) + .onConflictDoNothing({ + target: [ + tagChannelModel.tagId, + tagChannelModel.channelType, + tagChannelModel.integrationId, + ], + }) + .returning({ id: tagChannelModel.id }) + if (created) { + return created.id + } + + const retry = await tx.query.tagChannelModel.findFirst({ + where: { tagId, workspaceId, channelType, integrationId }, + columns: { id: true }, + }) + return retry?.id + } } export const tagService = new TagService() diff --git a/packages/business/src/workspace-member/service.ts b/packages/business/src/workspace-member/service.ts index b00e1ceed6..70b5f94781 100644 --- a/packages/business/src/workspace-member/service.ts +++ b/packages/business/src/workspace-member/service.ts @@ -251,6 +251,25 @@ export class WorkspaceMemberService extends BaseService { }, }) } + + /** + * Bulk membership validation for round-robin allocation — returns just the + * user ids from `userIds` that are actually members of the workspace. + */ + async listExistingUserIds(props: { + workspaceId: string + userIds: string[] + tx?: DatabaseClient + }): Promise<{ userId: string }[]> { + const { workspaceId, userIds, tx = db } = props + if (userIds.length === 0) { + return [] + } + return await tx.query.workspaceMemberModel.findMany({ + where: { workspaceId, userId: { in: userIds } }, + columns: { userId: true }, + }) + } } export const workspaceMemberService = new WorkspaceMemberService() diff --git a/packages/database/__tests__/coexist-sync-run-repository.test.ts b/packages/database/__tests__/coexist-sync-run-repository.test.ts index 1c65d6e966..5f476b4fc2 100644 --- a/packages/database/__tests__/coexist-sync-run-repository.test.ts +++ b/packages/database/__tests__/coexist-sync-run-repository.test.ts @@ -39,6 +39,11 @@ vi.mock("../src/schema", () => ({ attempts: "attempts", integrationId: "integrationId", channel: "channel", + currentScan: "currentScan", + importedContactCount: "importedContactCount", + importedMessageCount: "importedMessageCount", + skippedCount: "skippedCount", + failedCount: "failedCount", }, integrationInstagramModel: { id: "instagramId", @@ -235,4 +240,121 @@ describe("CoexistSyncRunRepository", () => { channel: "instagram", }) }) + + // --- Regression guards for the worker data-access refactor ------------- + // `claimRunForSync` is deliberately NOT `claimRun`: the coexist sync claim + // omits the `status IN ('init','running')` filter so a retry can reclaim a + // `failed`/`partial` run. Re-adding that filter silently breaks retry + // recovery, so assert its absence explicitly. + + test("claimRunForSync does NOT filter status IN ('init','running')", async () => { + const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + const repository = new CoexistSyncRunRepository() + + await expect( + repository.claimRunForSync({ + runId: "run-1", + touchUpdatedAt: true, + tx: { update } as never, + }), + ).resolves.toEqual({ id: "run-1" }) + + expect(mocks.inArray).not.toHaveBeenCalled() + // The stale-heartbeat fallback is the whole point of the claim: either the + // run is not currently running, or its heartbeat has gone stale. + expect(mocks.ne).toHaveBeenCalledWith("status", "running") + expect(mocks.lt).toHaveBeenCalledWith("lastHeartbeatAt", expect.anything()) + expect(where).toHaveBeenCalledWith( + expect.objectContaining({ + and: expect.arrayContaining([{ eq: ["runId", "run-1"] }]), + }), + ) + }) + + test("claimRunForSync touches updatedAt only when asked (messenger-sync yes, whatsapp-flush no)", async () => { + const repository = new CoexistSyncRunRepository() + + const makeTx = () => { + const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + return { set, tx: { update: vi.fn(() => ({ set })) } as never } + } + + const touched = makeTx() + await repository.claimRunForSync({ + runId: "run-1", + touchUpdatedAt: true, + tx: touched.tx, + }) + expect(touched.set.mock.calls[0]?.[0]).toHaveProperty("updatedAt") + + const untouched = makeTx() + await repository.claimRunForSync({ + runId: "run-1", + touchUpdatedAt: false, + tx: untouched.tx, + }) + expect(untouched.set.mock.calls[0]?.[0]).not.toHaveProperty("updatedAt") + }) + + test("incrementProgress uses an atomic `col + N` expression, never a read-modify-write", async () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const update = vi.fn(() => ({ set })) + const select = vi.fn() + const findFirst = vi.fn() + const repository = new CoexistSyncRunRepository() + + await repository.incrementProgress({ + runId: "run-1", + increments: { importedMessageCount: 5, skippedCount: 2 }, + fields: { currentStep: "importing" }, + tx: { + update, + select, + query: { coexistSyncRunModel: { findFirst } }, + } as never, + }) + + // No prior read: a read-modify-write would reintroduce a lost update + // across the two concurrent coexist phase workers. + expect(select).not.toHaveBeenCalled() + expect(findFirst).not.toHaveBeenCalled() + + const setArg = set.mock.calls[0]?.[0] as Record + // Each counter is a `sql` template of the form ` + `. + expect(setArg.importedMessageCount).toEqual({ + sql: [expect.anything(), ["importedMessageCount", 5]], + }) + expect(setArg.skippedCount).toEqual({ + sql: [expect.anything(), ["skippedCount", 2]], + }) + const [strings] = ( + setArg.importedMessageCount as { sql: [string[], unknown[]] } + ).sql + expect(strings.join("")).toContain("+") + // Plain-value fields ride along untouched. + expect(setArg.currentStep).toBe("importing") + expect(where).toHaveBeenCalledWith({ eq: ["runId", "run-1"] }) + }) + + test("incrementProgress skips counters whose increment is undefined", async () => { + const where = vi.fn().mockResolvedValue(undefined) + const set = vi.fn(() => ({ where })) + const repository = new CoexistSyncRunRepository() + + await repository.incrementProgress({ + runId: "run-1", + increments: { currentScan: 1, failedCount: undefined }, + tx: { update: vi.fn(() => ({ set })) } as never, + }) + + const setArg = set.mock.calls[0]?.[0] as Record + expect(setArg).toHaveProperty("currentScan") + expect(setArg).not.toHaveProperty("failedCount") + }) }) diff --git a/packages/database/src/repositories/coexist-sync-run/repository.ts b/packages/database/src/repositories/coexist-sync-run/repository.ts index 2221952296..b1d546d310 100644 --- a/packages/database/src/repositories/coexist-sync-run/repository.ts +++ b/packages/database/src/repositories/coexist-sync-run/repository.ts @@ -695,6 +695,140 @@ export class CoexistSyncRunRepository { aiReadsSyncedHistory: input.aiReadsSyncedHistory, }) } + + /** Read `lastSyncedAt` for phase resume. */ + async findLastSyncedAt(input: { + runId: string + tx?: DatabaseClient + }): Promise<{ lastSyncedAt: Date | null } | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { lastSyncedAt: true }, + })) ?? null + ) + } + + /** + * Atomically increments the given counters (`sql\`col + N\`` — NOT a + * read-modify-write, which would reintroduce a lost-update race across the + * two concurrent phase workers) while also setting the given plain-value + * fields. + */ + async incrementProgress(input: { + runId: string + increments: Partial< + Record< + | "currentScan" + | "importedContactCount" + | "importedMessageCount" + | "skippedCount" + | "failedCount", + number + > + > + fields?: CoexistRunProgressInput["fields"] + tx?: DatabaseClient + }): Promise { + const { tx = db, runId, increments, fields } = input + const incrementSet: Record = {} + for (const [key, amount] of Object.entries(increments)) { + if (amount === undefined) { + continue + } + const column = + coexistSyncRunModel[key as keyof typeof coexistSyncRunModel] + incrementSet[key] = sql`${column} + ${amount}` + } + + await tx + .update(coexistSyncRunModel) + .set({ ...incrementSet, ...fields, updatedAt: new Date() }) + .where(eq(coexistSyncRunModel.id, runId)) + } + + /** Init-row read (attempts/currentError/messengerSyncPhase) before claim. */ + async findInitState(input: { + runId: string + tx?: DatabaseClient + }): Promise | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { + attempts: true, + currentError: true, + messengerSyncPhase: true, + }, + })) ?? null + ) + } + + /** + * Optimistic claim with a stale-heartbeat fallback, used by + * `messenger-sync.ts` and `whatsapp-flush.ts`. Deliberately does NOT + * include `claimRun`'s `inArray(status, ["init","running"])` guard — both + * callers reclaim `failed`/`partial` runs on retry, so adding that guard + * would silently break retry recovery. `touchUpdatedAt` distinguishes the + * two callers' SET clauses (messenger-sync also bumps `updatedAt`; + * whatsapp-flush does not) — do not unify beyond this flag. + */ + async claimRunForSync(input: { + runId: string + touchUpdatedAt: boolean + tx?: DatabaseClient + }): Promise { + const { tx = db, runId, touchUpdatedAt } = input + const [run] = await tx + .update(coexistSyncRunModel) + .set({ + status: "running", + startedAt: sql`COALESCE(${coexistSyncRunModel.startedAt}, NOW())`, + lastHeartbeatAt: new Date(), + ...(touchUpdatedAt ? { updatedAt: new Date() } : {}), + }) + .where( + and( + eq(coexistSyncRunModel.id, runId), + or( + ne(coexistSyncRunModel.status, "running"), + lt( + coexistSyncRunModel.lastHeartbeatAt, + sql`NOW() - INTERVAL '10 minutes'`, + ), + ), + ), + ) + .returning() + + return run ?? null + } + + /** Terminal-status derivation counters (importedMessages/skipped/failed). */ + async findTerminalCounters(input: { + runId: string + tx?: DatabaseClient + }): Promise | null> { + const { tx = db } = input + return ( + (await tx.query.coexistSyncRunModel.findFirst({ + where: { id: input.runId }, + columns: { + importedMessageCount: true, + skippedCount: true, + failedCount: true, + }, + })) ?? null + ) + } } export const coexistSyncRunRepository = new CoexistSyncRunRepository() diff --git a/packages/database/src/repositories/contact-inbox/repository.ts b/packages/database/src/repositories/contact-inbox/repository.ts index c6578f6e8d..c09c63c9fd 100644 --- a/packages/database/src/repositories/contact-inbox/repository.ts +++ b/packages/database/src/repositories/contact-inbox/repository.ts @@ -20,7 +20,11 @@ import { integrationMessengerModel, integrationWhatsappModel, } from "../../schema" -import type { ContactInboxModel } from "../../types" +import type { + ContactInboxModel, + ContactModel, + ConversationModel, +} from "../../types" export type WhatsappCtwaInboxRow = { contactInboxId: string @@ -449,4 +453,87 @@ export const contactInboxRepository = { return perChannelRows.flat() }, + + /** + * Resolve a contact inbox with its `conversation` + `contact` relations, + * by an arbitrary `where` (e.g. `{ inboxId, sourceId }` or + * `{ inboxId, sourceUserId }`) — used by `message-status.ts`'s + * `resolveStatusContactInbox` behind `resolveWithSourceUserIdFallback`. + * Keep the caller's probe order/spread exactly as-is; this repo method + * only executes one shape of the query. + */ + findWithConversationAndContact( + props: { where: Record }, + tx: DatabaseClient = db, + ): Promise< + | (ContactInboxModel & { + conversation: ConversationModel | null + contact: ContactModel + }) + | undefined + > { + return tx.query.contactInboxModel.findFirst({ + where: props.where, + with: { conversation: true, contact: true }, + }) + }, + + /** + * Resolve a contact inbox with its `contact` relation, by an arbitrary + * `where` — used by `received-message.ts`'s `resolveExistingContactInbox` + * behind `resolveWithSourceUserIdFallback`. Keep the caller's + * `{ inboxId, channel, ...where }` spread and probe order exactly as-is. + */ + findWithContact( + props: { where: Record }, + tx: DatabaseClient = db, + ): Promise<(ContactInboxModel & { contact: ContactModel }) | undefined> { + return tx.query.contactInboxModel.findFirst({ + where: props.where, + with: { contact: true }, + }) + }, + + /** + * Resolve `{ id, contactId }` for contact inboxes matching an inbox + + * source-id list — used by `inbox_labels/sync.ts`'s `findInboxes` to map + * external label event user ids to local contacts. + */ + listIdsByInboxAndSourceIds( + props: { inboxId: string; sourceIds: string[] }, + tx: DatabaseClient = db, + ): Promise[]> { + return tx.query.contactInboxModel.findMany({ + where: { inboxId: props.inboxId, sourceId: { in: props.sourceIds } }, + columns: { id: true, contactId: true }, + }) + }, + + /** + * Map `sourceId → { id, lastIncomingMessageAt, createdAt }` for an inbox — + * used by `coexist/whatsapp-flush.ts` to resolve identity columns for a + * batch of staged contacts. + */ + listIdentityColumnsByInboxAndSourceIds( + props: { inboxId: string; sourceIds: string[] }, + tx: DatabaseClient = db, + ): Promise< + Pick< + ContactInboxModel, + "id" | "sourceId" | "lastIncomingMessageAt" | "createdAt" + >[] + > { + if (props.sourceIds.length === 0) { + return Promise.resolve([]) + } + return tx.query.contactInboxModel.findMany({ + where: { inboxId: props.inboxId, sourceId: { in: props.sourceIds } }, + columns: { + id: true, + sourceId: true, + lastIncomingMessageAt: true, + createdAt: true, + }, + }) + }, } diff --git a/packages/database/src/repositories/contact/repository.ts b/packages/database/src/repositories/contact/repository.ts index 6eabfaf79f..1968be3bb4 100644 --- a/packages/database/src/repositories/contact/repository.ts +++ b/packages/database/src/repositories/contact/repository.ts @@ -3,6 +3,7 @@ import { countWithRelationsFilterCapped, type DatabaseClient, db, + sql, } from "../../client" import { contactModel } from "../../schema" import { buildContactListWhere, resolveContactOrderBy } from "./list-where" @@ -120,4 +121,32 @@ export const contactRepository = { 0, ) }, + /** + * Fill `Contact.phoneNumber` / `Contact.email` only when currently NULL — + * moved VERBATIM from `bulk-historical-import.ts`'s contact-enrichment + * transaction. The double-`::text` cast and the compound `WHERE` guard are + * load-bearing; do not simplify. + */ + async enrichIfNull( + props: { + contactId: string + phoneNumber?: string + email?: string + }, + tx: DatabaseClient = db, + ): Promise { + const { contactId, phoneNumber, email } = props + await tx.transaction(async (innerTx) => { + await innerTx.execute(sql` + UPDATE "Contact" SET + "phoneNumber" = COALESCE("phoneNumber", ${phoneNumber ?? null}::text), + "email" = COALESCE("email", ${email ?? null}::text) + WHERE "id" = ${contactId} + AND ( + (${phoneNumber ?? null}::text IS NOT NULL AND "phoneNumber" IS NULL) + OR (${email ?? null}::text IS NOT NULL AND "email" IS NULL) + ) + `) + }) + }, } diff --git a/packages/database/src/repositories/import/index.ts b/packages/database/src/repositories/import/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/import/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/import/repository.ts b/packages/database/src/repositories/import/repository.ts new file mode 100644 index 0000000000..78fad56f70 --- /dev/null +++ b/packages/database/src/repositories/import/repository.ts @@ -0,0 +1,26 @@ +import { type DatabaseClient, db } from "../../client" + +/** + * Single-purpose repository for resolving an `Import`'s owning workspace by + * id. Runs inside the blocked-owner-guard fast path + * (`apps/worker/src/lib/resolve-workspace-id.ts`) — must stay one indexed + * primary-key read projecting only `workspaceId`, and must never throw (the + * guard treats `undefined` as fail-open, per AGENTS.md invariant 15). + * + * Follow-up: the `AI_WORKSPACE_SCOPES` union in + * `ai-workspace-scope/repository.ts` is close to a general "resolve + * workspace by record id" registry; folding `importId` into a renamed + * `workspaceScopeRepository` would remove this one-off repository. + */ +export const importRepository = { + async findWorkspaceId( + props: { id: string }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.importModel.findFirst({ + where: { id: props.id }, + columns: { workspaceId: true }, + }) + return row?.workspaceId + }, +} diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index 7b4cac7506..e73d941105 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -18,10 +18,12 @@ export * from "./conversation-ai-context" export * from "./coupon" export * from "./error-log" export * from "./file" +export * from "./import" export * from "./inbox" export * from "./integration-api" export * from "./integration-facebook-ads" export * from "./integration-instagram" +export * from "./integration-lookup" export * from "./integration-messenger" export * from "./integration-whatsapp" export * from "./media-library-file" diff --git a/packages/database/src/repositories/integration-lookup/index.ts b/packages/database/src/repositories/integration-lookup/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/integration-lookup/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/integration-lookup/repository.ts b/packages/database/src/repositories/integration-lookup/repository.ts new file mode 100644 index 0000000000..bc3c96027b --- /dev/null +++ b/packages/database/src/repositories/integration-lookup/repository.ts @@ -0,0 +1,44 @@ +import { type DatabaseClient, db, sql } from "../../client" + +/** + * Raw row shape for a dynamic per-channel integration table lookup. Callers + * (worker `IntegrationRow`) narrow `auth` to their own `AuthValue` type — + * this repository stays free of the `@chatbotx.io/sdk` dependency that + * `packages/database` does not carry. + */ +export type IntegrationLookupRow = { + id: string + auth: unknown + workspaceId?: string + inboxId: string + [x: string]: unknown +} + +/** + * Dynamic-table integration lookups keyed on an external identifier or an + * inbox id. `modelName`/`columnName`/`integrationTable` are always the + * output of an exhaustive `switch` in the caller (never raw user input) — + * `sql.identifier()` is the injection guard here and MUST be kept on both + * queries. + */ +export const integrationLookupRepository = { + async findAuthByIdentifier( + props: { modelName: string; columnName: string; identifier: string }, + tx: DatabaseClient = db, + ): Promise { + const result = await tx.execute( + sql`SELECT * FROM ${sql.identifier(props.modelName)} WHERE ${sql.identifier(props.columnName)} = ${props.identifier} LIMIT 1`, + ) + return result.rows[0] + }, + + async findAuthByInboxId( + props: { modelName: string; inboxId: string }, + tx: DatabaseClient = db, + ): Promise { + const result = await tx.execute( + sql`SELECT * FROM ${sql.identifier(props.modelName)} WHERE "inboxId" = ${props.inboxId} LIMIT 1`, + ) + return result.rows[0] + }, +} diff --git a/packages/database/src/repositories/integration-messenger/repository.ts b/packages/database/src/repositories/integration-messenger/repository.ts index 55649ce616..40c4fc0a18 100644 --- a/packages/database/src/repositories/integration-messenger/repository.ts +++ b/packages/database/src/repositories/integration-messenger/repository.ts @@ -327,4 +327,33 @@ export const integrationMessengerRepository = { return row ?? null }, + + /** + * Load a Messenger integration by id with NO workspace scope. Callers that + * have an id sourced from a workspace-scoped record elsewhere (e.g. a + * coexist sync run) must independently compare `workspaceId` themselves — + * do not treat this as a substitute for a workspace-scoped lookup. + */ + findById( + props: { id: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationMessengerModel.findFirst({ + where: { id: props.id }, + }) + }, + + /** + * Load a Messenger integration by Facebook page id with NO workspace scope + * — used by inbound webhooks (e.g. inbox-label sync) that only have the + * page id and have not yet resolved a workspace. + */ + findByPageIdUnscoped( + props: { pageId: string }, + tx: DatabaseClient = db, + ): Promise { + return tx.query.integrationMessengerModel.findFirst({ + where: { pageId: props.pageId }, + }) + }, } diff --git a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts index 3d03caa36c..278389146b 100644 --- a/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts +++ b/packages/database/src/repositories/whatsapp-coexist-staging/repository.ts @@ -41,6 +41,22 @@ const pendingFilter = (phoneNumberId: string) => ) export const whatsappCoexistStagingRepository = { + /** Idempotent staging insert keyed on `(phoneNumberId, payloadHash)` — keep untargeted `onConflictDoNothing()`. */ + async stagePayload( + props: { + id: string + phoneNumberId: string + payload: unknown + payloadHash: string + }, + tx: DatabaseClient = db, + ): Promise { + await tx + .insert(whatsappCoexistStagingModel) + .values(props) + .onConflictDoNothing() + }, + /** * Oldest-first page of staging rows still awaiting import for one phone * number. Always bounded by `limit` — the flush drains in chunks, and the diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index d1c0f0d0e6..04f1ddcc20 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -167,6 +167,8 @@ export type IntegrationInstagramModel = typeof schema.integrationInstagramModel.$inferSelect export type WhatsappMessageTemplateModel = typeof schema.whatsappMessageTemplateModel.$inferSelect +export type MessengerMessageTemplateModel = + typeof schema.messengerMessageTemplateModel.$inferSelect export type WhatsappFlowModel = typeof schema.whatsappFlowModel.$inferSelect export type FlowAnalyticsSessionModel = typeof schema.flowAnalyticsSessionModel.$inferSelect diff --git a/packages/sequence-scheduler/src/sequence-dispatch.ts b/packages/sequence-scheduler/src/sequence-dispatch.ts index 79ae6b7f7c..264677d75d 100644 --- a/packages/sequence-scheduler/src/sequence-dispatch.ts +++ b/packages/sequence-scheduler/src/sequence-dispatch.ts @@ -60,6 +60,105 @@ export const sequenceDispatchUtils = { bucket: d.bucket, })) }, + + /** Load a running dispatch — moved verbatim from `sequence-flow.ts` `fetchDispatch`. */ + findRunning: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + }) => { + const { dbClient, dispatchId, workspaceId } = props + return await dbClient.query.sequenceDispatchModel.findFirst({ + where: { + id: dispatchId, + workspaceId, + status: "running", + }, + }) + }, + + /** + * Mark a dispatch completed — moved verbatim from `sequence-flow.ts` + * `markDispatchCompleted`, keeping its `status = 'running'` guard + * (idempotency guard for job retries). + */ + markCompleted: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + sentAt: Date + }): Promise => { + const { dbClient, dispatchId, workspaceId, sentAt } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "completed", + completedAt: sentAt, + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, + + /** + * Mark a dispatch canceled — keeps the `status = 'running'` guard + * (idempotency guard for job retries). + */ + markCanceled: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + reason: string + }): Promise => { + const { dbClient, dispatchId, workspaceId, reason } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "canceled", + lastError: reason, + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, + + /** + * Mark a dispatch failed — keeps the `status = 'running'` guard + * (idempotency guard for job retries). + */ + markFailed: async (props: { + dbClient: DatabaseClient + dispatchId: string + workspaceId: string + errorMessage: string + }): Promise => { + const { dbClient, dispatchId, workspaceId, errorMessage } = props + await dbClient + .update(sequenceDispatchModel) + .set({ + status: "failed", + lastError: errorMessage, + failedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, dispatchId), + eq(sequenceDispatchModel.workspaceId, workspaceId), + eq(sequenceDispatchModel.status, "running"), + ), + ) + }, } export type SequenceDispatchUtils = typeof sequenceDispatchUtils From 41a85bf65802579eeb23f42c7ace08198efadb87 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 18:55:59 +0700 Subject: [PATCH 2/5] refactor(builder): move integration data access into business services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears direct `db` imports from apps/builder/src/features/integration-* across AI providers (claude/deepseek/gemini/openai), all seven channels (telegram/tiktok/zalo/webchat/messenger/instagram/whatsapp), message templates, smtp, and google-sheets — routing writes through packages/business services instead, per the action | api → service → repository → db chain. - New consolidated packages/business/src/integration-ai-provider/connect.ts shared by the four AI-provider services (structurally identical tables). - New packages/business/src/{messenger,whatsapp}-message-template and whatsapp-flow services extracted from what were misnamed app-layer "service" files doing Meta/WhatsApp API sync merged with raw DB writes. - Existing per-channel services (telegram/tiktok/zalo/webchat/messenger/ instagram/whatsapp/google-sheet/smtp) gained connect/disconnect/update methods; app-layer actions keep external API calls, quota checks, and redirect() orchestration. - Fixed pre-existing test mocks broken by the new import graph (missing service exports, @chatbotx.io/database/partials pulled in unmocked). Co-Authored-By: Claude Sonnet 5 fix: wip reason fix --- .../__tests__/disconnect-meta-actions.test.ts | 24 +- .../disconnect-whatsapp-action.test.ts | 67 +++-- .../integration-tiktok-connect.action.test.ts | 44 +-- ...essenger-clone-template-components.test.ts | 10 + .../__tests__/update-messenger-action.test.ts | 12 + .../update-webchat-action-permission.test.ts | 37 +-- .../actions/connect.action.ts | 68 +---- .../actions/update.action.ts | 21 +- .../integration-claude/queries/index.ts | 8 +- .../actions/connect.action.ts | 66 +---- .../actions/update.action.ts | 21 +- .../integration-deepseek/queries/index.ts | 6 +- .../actions/connect.action.ts | 69 +---- .../actions/update.action.ts | 21 +- .../integration-gemini/queries/index.ts | 8 +- .../actions/disconnect.action.ts | 21 +- .../actions/disconnect-instagram.ts | 25 +- .../actions/update-instagram-action.ts | 23 +- .../integration-instagram/queries/index.ts | 12 +- .../actions/__tests__/toggle-tag-sync.test.ts | 119 ++------ .../actions/disconnect-messenger.ts | 39 +-- .../actions/toggle-tag-sync.action.ts | 24 +- .../actions/update-messenger-action.ts | 20 +- .../actions/clone-message-templates.ts | 33 +-- .../actions/create-message-template.ts | 10 +- .../actions/delete-message-template.ts | 30 +- .../actions/sync-message-templates.ts | 89 +----- .../message-templates/queries/index.ts | 139 +--------- .../integration-messenger/queries/index.ts | 10 +- .../actions/connect.action.ts | 69 +---- .../actions/update-openai.action.ts | 26 +- .../integration-openai/queries/index.ts | 8 +- .../integration-smtp/queries/index.ts | 12 +- .../integration-smtp/services/smtp.service.ts | 170 ++---------- .../actions/connect.action.ts | 39 +-- .../actions/disconnect.action.ts | 23 +- .../integration-telegram/queries/index.ts | 17 +- .../actions/connect.action.ts | 41 +-- .../actions/disconnect.action.ts | 22 +- .../integration-tiktok/queries/index.ts | 19 +- .../actions/create-webchat.action.ts | 41 ++- .../actions/update-webchat.action.ts | 37 +-- .../integration-webchat/queries/index.ts | 36 +-- .../actions/disconnect.action.ts | 94 +------ .../automation/queries/index.ts | 15 +- .../flows/actions/sync-whatsapp-flows.ts | 81 +----- .../integration-whatsapp/flows/api/private.ts | 28 +- .../flows/queries/index.ts | 32 +-- .../actions/sync-message-templates.ts | 87 +----- .../message-templates/queries/index.ts | 34 +-- .../integration-whatsapp/queries/index.ts | 12 +- .../actions/__tests__/toggle-tag-sync.test.ts | 96 ++----- .../actions/connect-zalo.action.ts | 47 ++-- .../actions/disconnect.action.ts | 35 +-- .../actions/toggle-tag-sync.action.ts | 17 +- .../integration-zalo/queries/index.ts | 21 +- .../queries/get-ai-integrations.ts | 24 +- .../integration-inbox-lookup.test.ts | 16 ++ ...r-integration.service.connect-page.test.ts | 8 + packages/business/src/index.ts | 3 + .../src/integration-ai-provider/connect.ts | 87 ++++++ .../src/integration-claude/service.ts | 45 ++- .../src/integration-deepseek/service.ts | 45 ++- .../src/integration-gemini/service.ts | 45 ++- .../src/integration-google-sheet/service.ts | 11 +- .../src/integration-instagram/service.ts | 33 ++- .../src/integration-messenger/service.ts | 83 +++++- .../src/integration-openai/service.ts | 56 +++- .../business/src/integration-smtp/service.ts | 199 +++++++++++++- .../src/integration-telegram/service.ts | 92 ++++++- .../src/integration-tiktok/service.ts | 82 +++++- .../src/integration-webchat/service.ts | 52 ++++ .../src/integration-whatsapp/service.ts | 96 ++++++- .../business/src/integration-zalo/service.ts | 109 +++++++- packages/business/src/integration/service.ts | 25 ++ .../src/messenger-message-template/index.ts | 1 + .../src/messenger-message-template/service.ts | 258 ++++++++++++++++++ packages/business/src/whatsapp-flow/index.ts | 1 + .../business/src/whatsapp-flow/service.ts | 122 +++++++++ .../src/whatsapp-message-template/index.ts | 1 + .../src/whatsapp-message-template/service.ts | 121 ++++++++ .../integration-whatsapp/repository.ts | 15 + 82 files changed, 2123 insertions(+), 1742 deletions(-) create mode 100644 packages/business/src/integration-ai-provider/connect.ts create mode 100644 packages/business/src/messenger-message-template/index.ts create mode 100644 packages/business/src/messenger-message-template/service.ts create mode 100644 packages/business/src/whatsapp-flow/index.ts create mode 100644 packages/business/src/whatsapp-flow/service.ts create mode 100644 packages/business/src/whatsapp-message-template/index.ts create mode 100644 packages/business/src/whatsapp-message-template/service.ts diff --git a/apps/builder/__tests__/disconnect-meta-actions.test.ts b/apps/builder/__tests__/disconnect-meta-actions.test.ts index 715ea882b6..f9d35a2903 100644 --- a/apps/builder/__tests__/disconnect-meta-actions.test.ts +++ b/apps/builder/__tests__/disconnect-meta-actions.test.ts @@ -29,6 +29,18 @@ const mocks = vi.hoisted(() => { messengerDisconnect: vi.fn().mockResolvedValue(undefined), messengerDisconnectSafe: vi.fn(() => false), messengerExists: vi.fn().mockResolvedValue(false), + messengerServiceDisconnect: vi.fn( + (props: { + id: string + tx: { delete: (...args: unknown[]) => unknown } + }) => Promise.resolve(props.tx.delete()), + ), + instagramServiceDisconnect: vi.fn( + (props: { + id: string + tx: { delete: (...args: unknown[]) => unknown } + }) => Promise.resolve(props.tx.delete()), + ), instagramDisconnect: vi.fn().mockResolvedValue(undefined), instagramFacebookDisconnect: vi.fn().mockResolvedValue(undefined), subscribePageToAppWebhook: vi.fn().mockResolvedValue(undefined), @@ -47,8 +59,16 @@ vi.mock("@chatbotx.io/business", () => ({ tearDownForIntegration: mocks.coexistTearDownForIntegration, }, inboxService: { disconnect: mocks.inboxDisconnect }, - instagramIntegrationService: { existsForPage: mocks.instagramExists }, - messengerIntegrationService: { existsForPage: mocks.messengerExists }, + instagramIntegrationService: { + existsForPage: mocks.instagramExists, + findByIdForWorkspace: mocks.findOrFail, + disconnect: mocks.instagramServiceDisconnect, + }, + messengerIntegrationService: { + existsForPage: mocks.messengerExists, + findByIdForWorkspace: mocks.findOrFail, + disconnect: mocks.messengerServiceDisconnect, + }, workspaceService: { findById: mocks.workspaceFindById }, })) diff --git a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts index 50308abbb5..257052a53f 100644 --- a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts +++ b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest" +const LIVE_RUN_STATUSES = ["init", "running", "waiting"] + const mocks = vi.hoisted(() => { const txChain = { set: vi.fn(), @@ -30,34 +32,65 @@ const mocks = vi.hoisted(() => { } }) +// Mirrors `integrationWhatsappService.disconnect`'s real transaction body — +// the test asserts on these same tx calls, so the mock replicates them +// rather than mocking `@chatbotx.io/business` transitively (which would +// require booting the real business/database module graph). +const integrationWhatsappServiceDisconnect = vi.fn( + async (props: { + integrationWhatsapp: { id: string; inboxId: string; phoneNumberId: string } + ownerId: string + workspaceId: string + tx: typeof mocks.tx + }) => { + const tx = props.tx as unknown as { + update: (arg?: unknown) => { + set: (arg?: unknown) => { where: (arg?: unknown) => unknown } + } + delete: (arg?: unknown) => unknown + } + + tx.update() + .set() + .where({ + conditions: [ + { field: "integrationId", value: props.integrationWhatsapp.id }, + { field: "status", values: LIVE_RUN_STATUSES }, + ], + }) + tx.delete() + await mocks.metaCapiDeleteByIntegration( + { + workspaceId: props.workspaceId, + channel: "whatsapp", + integrationId: props.integrationWhatsapp.id, + }, + props.tx, + ) + tx.delete({ id: "whatsappId" }) + await mocks.inboxDisconnect({ + inboxId: props.integrationWhatsapp.inboxId, + ownerId: props.ownerId, + workspaceId: props.workspaceId, + tx: props.tx, + }) + }, +) + vi.mock("@chatbotx.io/business", () => ({ - inboxService: { disconnect: mocks.inboxDisconnect }, + integrationWhatsappService: { + disconnect: integrationWhatsappServiceDisconnect, + }, workspaceService: { findById: mocks.workspaceFindById }, })) vi.mock("@chatbotx.io/database/client", () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions })), db: { transaction: mocks.dbTransaction }, - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), findOrFail: mocks.findOrFail, - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), -})) - -vi.mock("@chatbotx.io/database/repositories", () => ({ - LIVE_RUN_STATUSES: ["init", "running", "waiting"], - metaCapiEventRepository: { - deleteByIntegration: mocks.metaCapiDeleteByIntegration, - }, })) vi.mock("@chatbotx.io/database/schema", () => ({ - coexistSyncRunModel: { - finishedAt: "finishedAt", - integrationId: "integrationId", - status: "status", - }, integrationWhatsappModel: { id: "whatsappId" }, - whatsappCoexistStagingModel: { phoneNumberId: "phoneNumberId" }, })) vi.mock("@chatbotx.io/integration-whatsapp", () => ({ diff --git a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts index 0667d83cda..7bf59a785e 100644 --- a/apps/builder/__tests__/integration-tiktok-connect.action.test.ts +++ b/apps/builder/__tests__/integration-tiktok-connect.action.test.ts @@ -3,21 +3,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ - connectChannelIntegration: vi.fn(), + connect: vi.fn(), findWorkspaceById: vi.fn(), transaction: vi.fn(), - insert: vi.fn(), - values: vi.fn(), - onConflictDoUpdate: vi.fn(), - returning: vi.fn(), auditRecord: vi.fn(), handleRequest: vi.fn(), - createId: vi.fn(() => "generated-integration-id"), redirect: vi.fn(), })) vi.mock("@chatbotx.io/business", () => ({ - connectChannelIntegration: mocks.connectChannelIntegration, + tiktokIntegrationService: { connect: mocks.connect }, workspaceService: { findById: mocks.findWorkspaceById }, })) @@ -40,14 +35,6 @@ vi.mock("@chatbotx.io/database/client", () => ({ db: { transaction: mocks.transaction }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationTiktokModel: { id: "id", openId: "openId" }, -})) - -vi.mock("@chatbotx.io/utils", () => ({ - createId: mocks.createId, -})) - vi.mock("next/navigation", () => ({ redirect: mocks.redirect, })) @@ -74,25 +61,15 @@ describe("connectTiktokHandler", () => { }, }) mocks.transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => - fn({ - insert: mocks.insert, - }), + fn({}), ) - mocks.insert.mockReturnValue({ values: mocks.values }) - mocks.values.mockReturnValue({ - onConflictDoUpdate: mocks.onConflictDoUpdate, - }) - mocks.onConflictDoUpdate.mockReturnValue({ returning: mocks.returning }) - mocks.returning.mockResolvedValue([{ id: "existing-integration-id" }]) }) test("records reconnect audit with the persisted TikTok integration id on conflict", async () => { - mocks.connectChannelIntegration.mockImplementation( - async ({ insertIntegration }) => { - const integration = await insertIntegration("inbox-1", false) - return { wasCreated: false, integration } - }, - ) + mocks.connect.mockResolvedValue({ + wasCreated: false, + integration: { id: "existing-integration-id" }, + }) await connectTiktokHandler({ tiktokSettings: { clientId: "client", clientSecret: "secret" }, @@ -102,15 +79,14 @@ describe("connectTiktokHandler", () => { redirectUrl: "https://app.example.com/integrations/tiktok/callback", }) - expect(mocks.values).toHaveBeenCalledWith( + expect(mocks.connect).toHaveBeenCalledWith( expect.objectContaining({ - id: "generated-integration-id", - inboxId: "inbox-1", workspaceId: "workspace-1", openId: "open-id-1", + username: "shop_1", + displayName: "TikTok Shop", }), ) - expect(mocks.returning).toHaveBeenCalledWith({ id: "id" }) expect(mocks.auditRecord).toHaveBeenCalledTimes(1) expect(mocks.auditRecord).toHaveBeenCalledWith({ userId: "admin-1", diff --git a/apps/builder/__tests__/messenger-clone-template-components.test.ts b/apps/builder/__tests__/messenger-clone-template-components.test.ts index a0d04e41de..43bf33c35d 100644 --- a/apps/builder/__tests__/messenger-clone-template-components.test.ts +++ b/apps/builder/__tests__/messenger-clone-template-components.test.ts @@ -17,6 +17,16 @@ vi.mock("@chatbotx.io/database/schema", () => ({ messengerMessageTemplateModel: {}, })) +vi.mock("@chatbotx.io/business", () => ({ + messengerIntegrationService: { + findByIdForWorkspace: vi.fn(), + findByIds: vi.fn(), + }, + messengerMessageTemplateService: { + findByIdForIntegration: vi.fn(), + }, +})) + vi.mock("@chatbotx.io/integration-messenger/apis/message-templates", () => ({ createPageMessageTemplate: vi.fn(), })) diff --git a/apps/builder/__tests__/update-messenger-action.test.ts b/apps/builder/__tests__/update-messenger-action.test.ts index 4027a53acd..f8c6778eaa 100644 --- a/apps/builder/__tests__/update-messenger-action.test.ts +++ b/apps/builder/__tests__/update-messenger-action.test.ts @@ -7,6 +7,14 @@ const mocks = vi.hoisted(() => { const txSet = vi.fn(() => ({ where: txWhere })) const txUpdate = vi.fn(() => ({ set: txSet })) + const updateProfileFields = vi.fn( + ( + _props: { id: string }, + _data: Record, + tx: { update: typeof txUpdate }, + ) => Promise.resolve(tx.update().set().where()), + ) + return { buildContext: vi.fn(), dbTransaction: vi.fn( @@ -22,11 +30,15 @@ const mocks = vi.hoisted(() => { txSet, txUpdate, txWhere, + updateProfileFields, } }) vi.mock("@chatbotx.io/business", () => ({ buildContext: mocks.buildContext, + messengerIntegrationService: { + updateProfileFields: mocks.updateProfileFields, + }, })) vi.mock("@chatbotx.io/business/branding", () => ({ diff --git a/apps/builder/__tests__/update-webchat-action-permission.test.ts b/apps/builder/__tests__/update-webchat-action-permission.test.ts index 5905a85420..1577619d8d 100644 --- a/apps/builder/__tests__/update-webchat-action-permission.test.ts +++ b/apps/builder/__tests__/update-webchat-action-permission.test.ts @@ -3,8 +3,8 @@ import { beforeEach, expect, test, vi } from "vitest" const mockHasWorkspacePermission = vi.fn() -const mockFindOrFail = vi.fn() -const mockDbTransaction = vi.fn() +const mockFindByWorkspaceIdAndId = vi.fn() +const mockUpdate = vi.fn() const mockIsCommunity = vi.fn(() => false) const SUPER_ADMIN_ERROR_RE = /super admin/i @@ -30,14 +30,15 @@ vi.mock("@/lib/auth/permission-routes", () => ({ hasWorkspacePermission: mockHasWorkspacePermission, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mockDbTransaction }, - eq: vi.fn(), - findOrFail: mockFindOrFail, +vi.mock("@chatbotx.io/business", () => ({ + integrationWebchatService: { + findByWorkspaceIdAndId: mockFindByWorkspaceIdAndId, + update: mockUpdate, + }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationWebchatModel: { id: "id-column" }, +vi.mock("@chatbotx.io/business/branding", () => ({ + ensureBrandingMenuEntry: vi.fn((menus: unknown) => menus), })) const { updateWebchatAction } = await import( @@ -61,12 +62,7 @@ const makeInput = (permissions: Record) => ({ beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue({ id: "webchat-1" }) - mockDbTransaction.mockImplementation(async (fn: (tx: unknown) => unknown) => - fn({ - update: () => ({ set: () => ({ where: vi.fn() }) }), - }), - ) + mockUpdate.mockResolvedValue(undefined) }) test("rejects a workspace member without superAdmin permission", async () => { @@ -81,8 +77,7 @@ test("rejects a workspace member without superAdmin permission", async () => { // The permission check must short-circuit before any write is attempted — // this is the guard that closes the bypass of the edit page's // requireWorkspacePermission(workspaceId, "superAdmin") gate. - expect(mockFindOrFail).not.toHaveBeenCalled() - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() }) test("gates on the permissions supplied by the middleware ctx", async () => { @@ -98,7 +93,7 @@ test("gates on the permissions supplied by the middleware ctx", async () => { { superAdmin: false }, "superAdmin", ) - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() }) test("proceeds to update when the caller is a superAdmin", async () => { @@ -108,10 +103,8 @@ test("proceeds to update when the caller is a superAdmin", async () => { makeInput({ superAdmin: true }), ) - expect(mockFindOrFail).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "webchat-1", workspaceId: "workspace-1" }, - }), + expect(mockUpdate).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "webchat-1" }, + expect.objectContaining({ name: "Support" }), ) - expect(mockDbTransaction).toHaveBeenCalled() }) diff --git a/apps/builder/src/features/integration-claude/actions/connect.action.ts b/apps/builder/src/features/integration-claude/actions/connect.action.ts index 259283b0ef..0027da5099 100644 --- a/apps/builder/src/features/integration-claude/actions/connect.action.ts +++ b/apps/builder/src/features/integration-claude/actions/connect.action.ts @@ -1,15 +1,7 @@ "use server" - import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationClaudeModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationClaudeService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -44,50 +36,12 @@ export const connectClaudeAction = workspaceActionClient }) } - const integrationClaude = await db.query.integrationClaudeModel.findFirst( - { - where: { workspaceId }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationClaude) { - await tx - .update(integrationClaudeModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationClaudeModel.id, integrationClaude.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "claude", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationClaudeModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationClaudeService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -95,14 +49,6 @@ export const connectClaudeAction = workspaceActionClient aiProviders.enum.claude, ) - await auditService.record({ - workspaceId, - action: integrationClaude ? "update" : "connect", - detail: integrationClaude - ? "updated the Claude integration configuration" - : "connected a new Claude integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-claude/actions/update.action.ts b/apps/builder/src/features/integration-claude/actions/update.action.ts index 7c46f626f5..08cb7b89b9 100644 --- a/apps/builder/src/features/integration-claude/actions/update.action.ts +++ b/apps/builder/src/features/integration-claude/actions/update.action.ts @@ -2,9 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationClaudeModel } from "@chatbotx.io/database/schema" +import { integrationClaudeService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -26,26 +24,11 @@ export const updateIntegrationClaudeAction = workspaceActionClient parsedInput: UpdateClaudeRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationClaude = await findOrFail({ - table: integrationClaudeModel, - where: { workspaceId }, - message: "Integration Claude not found", - }) - - await db - .update(integrationClaudeModel) - .set(parsedInput) - .where(eq(integrationClaudeModel.id, integrationClaude.id)) + await integrationClaudeService.update(workspaceId, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, aiProviders.enum.claude, ) - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the Claude integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-claude/queries/index.ts b/apps/builder/src/features/integration-claude/queries/index.ts index 0c2870ee0c..e61e83f785 100644 --- a/apps/builder/src/features/integration-claude/queries/index.ts +++ b/apps/builder/src/features/integration-claude/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationClaudeService } from "@chatbotx.io/business" import type { IntegrationClaudeResource } from "../schema/resource" export const findIntegrationClaude = async ({ @@ -6,8 +6,4 @@ export const findIntegrationClaude = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationClaudeModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null + (await integrationClaudeService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-deepseek/actions/connect.action.ts b/apps/builder/src/features/integration-deepseek/actions/connect.action.ts index 58db5ef6cd..cf49438bfe 100644 --- a/apps/builder/src/features/integration-deepseek/actions/connect.action.ts +++ b/apps/builder/src/features/integration-deepseek/actions/connect.action.ts @@ -2,14 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationDeepseekModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationDeepSeekService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -44,49 +37,12 @@ export const connectDeepSeekAction = workspaceActionClient }) } - const integrationDeepseek = - await db.query.integrationDeepseekModel.findFirst({ - where: { workspaceId }, - }) - - await db.transaction(async (tx) => { - if (integrationDeepseek) { - await tx - .update(integrationDeepseekModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationDeepseekModel.id, integrationDeepseek.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "deepseek", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationDeepseekModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationDeepSeekService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -94,14 +50,6 @@ export const connectDeepSeekAction = workspaceActionClient aiProviders.enum.deepseek, ) - await auditService.record({ - workspaceId, - action: integrationDeepseek ? "update" : "connect", - detail: integrationDeepseek - ? "updated the DeepSeek integration configuration" - : "connected a new DeepSeek integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-deepseek/actions/update.action.ts b/apps/builder/src/features/integration-deepseek/actions/update.action.ts index 73cccc8cd0..a625615b27 100644 --- a/apps/builder/src/features/integration-deepseek/actions/update.action.ts +++ b/apps/builder/src/features/integration-deepseek/actions/update.action.ts @@ -2,9 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationDeepseekModel } from "@chatbotx.io/database/schema" +import { integrationDeepSeekService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -26,26 +24,11 @@ export const updateIntegrationDeepSeekAction = workspaceActionClient parsedInput: UpdateDeepSeekRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationDeepseek = await findOrFail({ - table: integrationDeepseekModel, - where: { workspaceId }, - message: "Integration DeepSeek not found", - }) - - await db - .update(integrationDeepseekModel) - .set(parsedInput) - .where(eq(integrationDeepseekModel.id, integrationDeepseek.id)) + await integrationDeepSeekService.update(workspaceId, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, aiProviders.enum.deepseek, ) - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the DeepSeek integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-deepseek/queries/index.ts b/apps/builder/src/features/integration-deepseek/queries/index.ts index 00739478f1..fd0476d13d 100644 --- a/apps/builder/src/features/integration-deepseek/queries/index.ts +++ b/apps/builder/src/features/integration-deepseek/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationDeepSeekService } from "@chatbotx.io/business" import type { IntegrationDeepseekResource } from "../schema/resource" export const findIntegrationDeepSeek = async ({ @@ -6,6 +6,4 @@ export const findIntegrationDeepSeek = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationDeepseekModel.findFirst({ - where: { workspaceId }, - })) ?? null + (await integrationDeepSeekService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-gemini/actions/connect.action.ts b/apps/builder/src/features/integration-gemini/actions/connect.action.ts index d52e6930c6..3aab8a7e6a 100644 --- a/apps/builder/src/features/integration-gemini/actions/connect.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/connect.action.ts @@ -1,13 +1,6 @@ "use server" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationGeminiModel, - integrationModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationGeminiService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -42,64 +35,16 @@ export const connectGeminiAction = workspaceActionClient }) } - const integrationGemini = await db.query.integrationGeminiModel.findFirst( - { - where: { - workspaceId, - }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationGemini) { - await tx - .update(integrationGeminiModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationGeminiModel.id, integrationGemini.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - workspaceId, - integrationType: "gemini", - id: createId(), - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationGeminiModel).values({ - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - id: createId(), - integrationId: integration.id, - }) - } + await integrationGeminiService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache(workspaceId, "gemini") - await auditService.record({ - workspaceId, - action: integrationGemini ? "update" : "connect", - detail: integrationGemini - ? "updated the Gemini integration configuration" - : "connected a new Gemini integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-gemini/actions/update.action.ts b/apps/builder/src/features/integration-gemini/actions/update.action.ts index 8a5509ffe1..3b630d2ec5 100644 --- a/apps/builder/src/features/integration-gemini/actions/update.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/update.action.ts @@ -1,8 +1,6 @@ "use server" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationGeminiModel } from "@chatbotx.io/database/schema" +import { integrationGeminiService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -24,23 +22,8 @@ export const updateGeminiAction = workspaceActionClient parsedInput: UpdateGeminiRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const integrationGemini = await findOrFail({ - table: integrationGeminiModel, - where: { workspaceId }, - message: "Integration Gemini not found", - }) - - await db - .update(integrationGeminiModel) - .set(parsedInput) - .where(eq(integrationGeminiModel.id, integrationGemini.id)) + await integrationGeminiService.update(workspaceId, parsedInput) await aiIntegrationService.invalidateCache(workspaceId, "gemini") - - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the Gemini integration configuration", - }) }, ) diff --git a/apps/builder/src/features/integration-gemini/queries/index.ts b/apps/builder/src/features/integration-gemini/queries/index.ts index a174bfa0ad..fe2bd5af90 100644 --- a/apps/builder/src/features/integration-gemini/queries/index.ts +++ b/apps/builder/src/features/integration-gemini/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationGeminiService } from "@chatbotx.io/business" import type { IntegrationGeminiResource } from "../schema/resource" export const findIntegrationGemini = async ({ @@ -6,8 +6,4 @@ export const findIntegrationGemini = async ({ }: { workspaceId: string }): Promise => - (await db.query.integrationGeminiModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null + (await integrationGeminiService.findByWorkspaceId(workspaceId)) ?? null diff --git a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts index e790fe7422..8c9c02eed7 100644 --- a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts @@ -1,11 +1,7 @@ "use server" +import { integrationGoogleSheetService } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { - integrationGoogleSheetsModel, - integrationModel, -} from "@chatbotx.io/database/schema" import { type GoogleSheetsAuthValue, integration as integrationGoogleSheets, @@ -25,13 +21,8 @@ export const disconnectGoogleSheetsAction = authActionClient }: { bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - const googleSheets = await findOrFail({ - table: integrationGoogleSheetsModel, - where: { - workspaceId, - }, - message: "Integration Google Sheets not found", - }) + const googleSheets = + await integrationGoogleSheetService.findByWorkspaceIdOrFail(workspaceId) try { await integrationGoogleSheets.disconnect?.( googleSheets.auth as GoogleSheetsAuthValue, @@ -43,11 +34,7 @@ export const disconnectGoogleSheetsAction = authActionClient ) } - await db.transaction(async (tx) => { - await tx - .delete(integrationModel) - .where(eq(integrationModel.id, googleSheets.integrationId)) - }) + await integrationGoogleSheetService.disconnect(googleSheets.integrationId) await auditService.record({ workspaceId, diff --git a/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts b/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts index 311c3939b7..7cd4dc8ac0 100644 --- a/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts +++ b/apps/builder/src/features/integration-instagram/actions/disconnect-instagram.ts @@ -1,13 +1,13 @@ import { coexistService, inboxService, + instagramIntegrationService, messengerIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { db } from "@chatbotx.io/database/client" import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" -import { integrationInstagramModel } from "@chatbotx.io/database/schema" import { type InstagramAuthValue, isRevokedTokenError, @@ -21,17 +21,17 @@ export const disconnectInstagram = async (ctx: { integrationInstagramId: string }) => { const [integrationInstagram, workspace] = await Promise.all([ - findOrFail({ - table: integrationInstagramModel, - where: { - id: ctx.integrationInstagramId, - workspaceId: ctx.workspaceId, - }, - message: "Integration Instagram not found", + instagramIntegrationService.findByIdForWorkspace({ + id: ctx.integrationInstagramId, + workspaceId: ctx.workspaceId, }), workspaceService.findById({ id: ctx.workspaceId }), ]) + if (!integrationInstagram) { + throw new Error("Integration Instagram not found") + } + const authValue = integrationInstagram.auth as InstagramAuthValue const isFacebook = integrationInstagram.type === "facebook" @@ -91,9 +91,10 @@ export const disconnectInstagram = async (ctx: { tx, ) - await tx - .delete(integrationInstagramModel) - .where(eq(integrationInstagramModel.id, integrationInstagram.id)) + await instagramIntegrationService.disconnect({ + id: integrationInstagram.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationInstagram.inboxId, diff --git a/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts b/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts index eb4445ebb9..ced8826341 100644 --- a/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts +++ b/apps/builder/src/features/integration-instagram/actions/update-instagram-action.ts @@ -1,18 +1,18 @@ "use server" -import { buildContext } from "@chatbotx.io/business" +import { + buildContext, + instagramIntegrationService, +} from "@chatbotx.io/business" import { moveBrandingMenuLast } from "@chatbotx.io/business/branding" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { db, findOrFail } from "@chatbotx.io/database/client" import { type InstagramConversationStarter, type InstagramPersistentMenu, instagramPersistentMenuTypes, } from "@chatbotx.io/database/partials" -import { - flowVersionModel, - integrationInstagramModel, -} from "@chatbotx.io/database/schema" +import { flowVersionModel } from "@chatbotx.io/database/schema" import type { IntegrationInstagramModel, WorkspaceModel, @@ -59,14 +59,15 @@ export const updateInstagramAction = workspaceActionClient id, }) - await tx - .update(integrationInstagramModel) - .set({ + await instagramIntegrationService.updateProfileFields( + { id }, + { welcomeFlowId: parsedInput.welcomeFlowId, conversationStarters: parsedInput.conversationStarters, persistentMenus: parsedInput.persistentMenus, - }) - .where(eq(integrationInstagramModel.id, id)) + }, + tx, + ) if (integrationInstagramData) { const auth = integrationInstagramData.auth as InstagramAuthValue diff --git a/apps/builder/src/features/integration-instagram/queries/index.ts b/apps/builder/src/features/integration-instagram/queries/index.ts index ea34de5f19..78f76bed4e 100644 --- a/apps/builder/src/features/integration-instagram/queries/index.ts +++ b/apps/builder/src/features/integration-instagram/queries/index.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { instagramIntegrationService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationInstagramModel } from "@chatbotx.io/database/schema" import type { IntegrationInstagramModel } from "@chatbotx.io/database/types" @@ -16,14 +17,7 @@ export const listIntegrationInstagrams = async ({ }: { workspaceId: string }): Promise<{ data: IntegrationInstagramModel[] }> => { - const data = await db.query.integrationInstagramModel.findMany({ - where: { - workspaceId, - }, - orderBy: { - createdAt: "asc", - }, - }) + const data = await instagramIntegrationService.listByWorkspaceId(workspaceId) return { data } } diff --git a/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts b/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts index bcd508f4ab..b02f4152b5 100644 --- a/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts +++ b/apps/builder/src/features/integration-messenger/actions/__tests__/toggle-tag-sync.test.ts @@ -37,43 +37,16 @@ vi.mock("@/features/workspace-members/queries", () => ({ })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/client -// Chainable builder: db.update(model).set(…).where(…).returning() +// Mock @chatbotx.io/database/client — findOrFail is still reached by the +// workspaceActionClient auth chain. // --------------------------------------------------------------------------- -const returningResult: { current: { syncTagEnabledAt: Date | null }[] } = { - current: [], -} - -const dbUpdateBuilder = { - set: vi.fn(), - where: vi.fn(), - returning: vi.fn(), -} - vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: vi.fn(), - }, findOrFail: vi.fn(), isDatabaseError: vi.fn(() => false), - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/schema -// --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationMessengerModel: { - id: "id", - workspaceId: "workspaceId", - syncTagEnabledAt: "syncTagEnabledAt", - }, - userModel: { id: "id" }, -})) - -// --------------------------------------------------------------------------- -// Mock @chatbotx.io/business (isPlatformAdmin) and errors +// Mock @chatbotx.io/business (isPlatformAdmin, messengerIntegrationService) and errors // // This factory mock enumerates exports, so it must cover everything // `workspaceActionClient` reaches — not just what this action calls directly. @@ -82,6 +55,8 @@ vi.mock("@chatbotx.io/database/schema", () => ({ // unrelated failure. `isWorkspaceScheduledForDeletion` is the deletion gate in // `lib/safe-action.ts`; `false` = an active workspace, this action's precondition. // --------------------------------------------------------------------------- +const updateTagSync = vi.fn() + vi.mock("@chatbotx.io/business", () => ({ isPlatformAdmin: vi.fn(async () => false), isWorkspaceScheduledForDeletion: vi.fn(() => false), @@ -95,6 +70,7 @@ vi.mock("@chatbotx.io/business", () => ({ isSupportSession: false, } }), + messengerIntegrationService: { updateTagSync }, })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -129,7 +105,7 @@ const { toggleMessengerTagSyncAction } = await import( "../toggle-tag-sync.action" ) const { invalidateCacheByTags } = await import("@chatbotx.io/redis") -const { db, findOrFail } = await import("@chatbotx.io/database/client") +const { findOrFail } = await import("@chatbotx.io/database/client") const { getCurrentUserId } = await import("@/lib/auth/utils") const { getAllWorkspaceMembers } = await import( "@/features/workspace-members/queries" @@ -138,7 +114,6 @@ const { getAllWorkspaceMembers } = await import( const invalidateCacheByTagsMock = invalidateCacheByTags as ReturnType< typeof vi.fn > -const dbUpdate = db.update as ReturnType const findOrFailMock = findOrFail as ReturnType const getCurrentUserIdMock = getCurrentUserId as ReturnType const getAllWorkspaceMembersMock = getAllWorkspaceMembers as ReturnType< @@ -173,52 +148,29 @@ describe("toggleMessengerTagSyncAction", () => { workspaceIds: [WORKSPACE_ID], }) - // Re-wire the chainable DB builder - dbUpdateBuilder.set.mockReturnValue(dbUpdateBuilder) - dbUpdateBuilder.where.mockReturnValue(dbUpdateBuilder) - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - dbUpdate.mockReturnValue(dbUpdateBuilder) + updateTagSync.mockResolvedValue(null) }) // ── enabled: true ────────────────────────────────────────────────────────── describe("enabled: true", () => { - test("sets syncTagEnabledAt to a Date instance (not null)", async () => { + test("returns the Date instance from the service (not null)", async () => { const now = new Date() - returningResult.current = [{ syncTagEnabledAt: now }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(now) const result = await invokeAction(true) - expect(dbUpdate).toHaveBeenCalledTimes(1) - - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeInstanceOf(Date) - expect(setArg.syncTagEnabledAt).not.toBeNull() - - // Return value exposes syncTagEnabledAt from the DB row + expect(updateTagSync).toHaveBeenCalledTimes(1) + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: true, + }) expect(result?.data?.syncTagEnabledAt).toBeInstanceOf(Date) }) - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - returningResult.current = [{ syncTagEnabledAt: new Date() }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - - await invokeAction(true) - - expect(dbUpdateBuilder.where).toHaveBeenCalledTimes(1) - // Our and() mock spreads its args into an array — the array should contain - // exactly two eq() predicate results (one per field). - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) - test("calls invalidateCacheByTags with the workspace-scoped messenger key", async () => { - returningResult.current = [{ syncTagEnabledAt: new Date() }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(new Date()) await invokeAction(true) @@ -232,21 +184,20 @@ describe("toggleMessengerTagSyncAction", () => { // ── enabled: false ───────────────────────────────────────────────────────── describe("enabled: false", () => { - test("sets syncTagEnabledAt to null", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + test("passes enabled: false to the service", async () => { + updateTagSync.mockResolvedValue(null) await invokeAction(false) - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeNull() + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: false, + }) }) test("calls invalidateCacheByTags with the workspace-scoped messenger key", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) + updateTagSync.mockResolvedValue(null) await invokeAction(false) @@ -254,35 +205,21 @@ describe("toggleMessengerTagSyncAction", () => { `workspaces:${WORKSPACE_ID}#messengers`, ]) }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - returningResult.current = [{ syncTagEnabledAt: null }] - dbUpdateBuilder.returning.mockResolvedValue(returningResult.current) - - await invokeAction(false) - - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) }) // ── no matching row ──────────────────────────────────────────────────────── - describe("no matching row (returning empty array)", () => { + describe("no matching row (service returns null)", () => { test("returns { syncTagEnabledAt: null } without throwing", async () => { - returningResult.current = [] - dbUpdateBuilder.returning.mockResolvedValue([]) + updateTagSync.mockResolvedValue(null) const result = await invokeAction(true) - // updated[0] is undefined → falls back to null via `?? null` expect(result?.data?.syncTagEnabledAt).toBeNull() }) test("still calls invalidateCacheByTags even when no row was updated", async () => { - returningResult.current = [] - dbUpdateBuilder.returning.mockResolvedValue([]) + updateTagSync.mockResolvedValue(null) await invokeAction(false) diff --git a/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts b/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts index c0d811afc2..1342c7fe6e 100644 --- a/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts +++ b/apps/builder/src/features/integration-messenger/actions/disconnect-messenger.ts @@ -2,16 +2,12 @@ import { coexistService, inboxService, instagramIntegrationService, + messengerIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, findOrFail } from "@chatbotx.io/database/client" -import { channelTypes } from "@chatbotx.io/database/partials" +import { db } from "@chatbotx.io/database/client" import { metaCapiEventRepository } from "@chatbotx.io/database/repositories" -import { - integrationMessengerModel, - tagChannelModel, -} from "@chatbotx.io/database/schema" import { isDisconnectSafeError, type MessengerAuthValue, @@ -25,17 +21,17 @@ export const disconnectMessenger = async (ctx: { id: string }) => { const [integrationMessenger, workspace] = await Promise.all([ - findOrFail({ - table: integrationMessengerModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Integration Messenger not found", + messengerIntegrationService.findByIdForWorkspace({ + id: ctx.id, + workspaceId: ctx.workspaceId, }), workspaceService.findById({ id: ctx.workspaceId }), ]) + if (!integrationMessenger) { + throw new Error("Integration Messenger not found") + } + const authValue = integrationMessenger.auth as MessengerAuthValue const hasSharedInstagramIntegration = @@ -90,16 +86,6 @@ export const disconnectMessenger = async (ctx: { tx, }) - // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId - await tx - .delete(tagChannelModel) - .where( - and( - eq(tagChannelModel.channelType, channelTypes.enum.messenger), - eq(tagChannelModel.integrationId, integrationMessenger.id), - ), - ) - // Polymorphic FK cleanup — stale MetaCapiEvent rows would keep occupying // the (workspaceId, channel, sourceKey) dedup slot after a reconnect. await metaCapiEventRepository.deleteByIntegration( @@ -111,9 +97,10 @@ export const disconnectMessenger = async (ctx: { tx, ) - await tx - .delete(integrationMessengerModel) - .where(eq(integrationMessengerModel.id, integrationMessenger.id)) + await messengerIntegrationService.disconnect({ + id: integrationMessenger.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationMessenger.inboxId, diff --git a/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts b/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts index 16d2d93cc1..c1f64c7d05 100644 --- a/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts +++ b/apps/builder/src/features/integration-messenger/actions/toggle-tag-sync.action.ts @@ -1,7 +1,6 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { messengerIntegrationService } from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -16,22 +15,13 @@ export const toggleMessengerTagSyncAction = workspaceActionClient parsedInput: { enabled }, } = props - const updated = await db - .update(integrationMessengerModel) - .set({ syncTagEnabledAt: enabled ? new Date() : null }) - .where( - and( - eq(integrationMessengerModel.id, integrationId), - eq(integrationMessengerModel.workspaceId, workspaceId), - ), - ) - .returning({ - syncTagEnabledAt: integrationMessengerModel.syncTagEnabledAt, - }) + const syncTagEnabledAt = await messengerIntegrationService.updateTagSync({ + workspaceId, + integrationId, + enabled, + }) await invalidateCacheByTags([`workspaces:${workspaceId}#messengers`]) - return { - syncTagEnabledAt: updated[0]?.syncTagEnabledAt ?? null, - } + return { syncTagEnabledAt } }) diff --git a/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts b/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts index e9c2248954..c399042f95 100644 --- a/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts +++ b/apps/builder/src/features/integration-messenger/actions/update-messenger-action.ts @@ -1,11 +1,14 @@ "use server" -import { buildContext, type IntegrationContext } from "@chatbotx.io/business" +import { + buildContext, + type IntegrationContext, + messengerIntegrationService, +} from "@chatbotx.io/business" import { moveBrandingMenuLast } from "@chatbotx.io/business/branding" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq } from "@chatbotx.io/database/client" +import { db } from "@chatbotx.io/database/client" import type { MessengerPersona } from "@chatbotx.io/database/partials" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel, WorkspaceModel, @@ -83,14 +86,15 @@ export const updateMessenger = async ( ) } - await tx - .update(integrationMessengerModel) - .set({ + await messengerIntegrationService.updateProfileFields( + { id: ctx.id }, + { ...parsedInput, personas: syncedPersonas, personaId: defaultPersona?.facebookPersonaId ?? null, - }) - .where(eq(integrationMessengerModel.id, ctx.id)) + }, + tx, + ) botContext = await buildContext({ workspaceId: ctx.workspace.id, diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts index d68a9f6f7a..4b0c30a56b 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts @@ -1,7 +1,9 @@ "use server" -import { db, inArray } from "@chatbotx.io/database/client" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { + messengerIntegrationService, + messengerMessageTemplateService, +} from "@chatbotx.io/business" import { createPageMessageTemplate } from "@chatbotx.io/integration-messenger/apis/message-templates" import { resumableUploadImage } from "@chatbotx.io/integration-messenger/apis/upload" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" @@ -140,14 +142,10 @@ export const cloneMessengerMessageTemplateAction = workspaceActionClient // Load source template, verifying it belongs to the source integration + workspace const sourceTemplate = - await db.query.messengerMessageTemplateModel.findFirst({ - where: { - id: templateId, - integrationMessengerId: sourceIntegrationMessengerId, - integrationMessenger: { - workspaceId, - }, - }, + await messengerMessageTemplateService.findByIdForIntegration({ + id: templateId, + integrationMessengerId: sourceIntegrationMessengerId, + workspaceId, }) if (!sourceTemplate) { @@ -156,18 +154,15 @@ export const cloneMessengerMessageTemplateAction = workspaceActionClient // Source integration (for its pageId — never clone a template onto its own page). const sourceIntegration = - await db.query.integrationMessengerModel.findFirst({ - where: { id: sourceIntegrationMessengerId, workspaceId }, - columns: { pageId: true }, + await messengerIntegrationService.findByIdForWorkspace({ + id: sourceIntegrationMessengerId, + workspaceId, }) // Resolve target rows by id (targets may live in OTHER workspaces). - const candidateTargets = await db - .select() - .from(integrationMessengerModel) - .where( - inArray(integrationMessengerModel.id, targetIntegrationMessengerIds), - ) + const candidateTargets = await messengerIntegrationService.findByIds( + targetIntegrationMessengerIds, + ) // Authorize per target: the user must be an owner of the target's workspace, // and the target must not be the source's own Facebook Page. diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts index 37e5ee5383..1e2a1a7b60 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/create-message-template.ts @@ -1,6 +1,6 @@ "use server" -import { db } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" import { createPageMessageTemplate } from "@chatbotx.io/integration-messenger/apis/message-templates" import { resumableUploadImage } from "@chatbotx.io/integration-messenger/apis/upload" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" @@ -35,11 +35,9 @@ export const createMessengerMessageTemplateAction = workspaceActionClient } = props const integrationMessenger = - await db.query.integrationMessengerModel.findFirst({ - where: { - id: integrationMessengerId, - workspaceId, - }, + await messengerIntegrationService.findByIdForWorkspace({ + id: integrationMessengerId, + workspaceId, }) if (!integrationMessenger) { diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts index 3274b2e9ca..9f706e3e29 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/delete-message-template.ts @@ -1,7 +1,9 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" +import { + messengerIntegrationService, + messengerMessageTemplateService, +} from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,29 +20,19 @@ export const deleteMessengerMessageTemplateAction = workspaceActionClient } = props // Verify the integration belongs to the workspace - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { - id: integrationMessengerId, - workspaceId, - }, - columns: { id: true }, + const integration = await messengerIntegrationService.findByIdForWorkspace({ + id: integrationMessengerId, + workspaceId, }) if (!integration) { throw new Error("Messenger integration not found") } - await db - .delete(messengerMessageTemplateModel) - .where( - and( - eq(messengerMessageTemplateModel.id, templateId), - eq( - messengerMessageTemplateModel.integrationMessengerId, - integrationMessengerId, - ), - ), - ) + await messengerMessageTemplateService.delete({ + id: templateId, + integrationMessengerId, + }) await invalidateCacheByTags([ `workspaces:${workspaceId}#messenger#messageTemplates`, diff --git a/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts b/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts index ba74f806c3..74fc6ce96b 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/actions/sync-message-templates.ts @@ -1,16 +1,15 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" import { - integrationMessengerModel, - messengerMessageTemplateModel, -} from "@chatbotx.io/database/schema" + buildContext, + messengerIntegrationService, + messengerMessageTemplateService, +} from "@chatbotx.io/business" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { SdkException } from "@chatbotx.io/sdk" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -66,68 +65,10 @@ export async function syncMessengerMessageTemplatesForIntegration({ return true }) - await db.transaction(async (tx) => { - if (!isPartialSync) { - const existingTemplates = await tx - .select({ - id: messengerMessageTemplateModel.id, - sourceId: messengerMessageTemplateModel.sourceId, - }) - .from(messengerMessageTemplateModel) - .where( - eq( - messengerMessageTemplateModel.integrationMessengerId, - integrationMessenger.id, - ), - ) - - const incomingSourceIds = new Set(templates.map((t) => t.id)) - - const templatesToDelete = existingTemplates.filter( - (t) => !incomingSourceIds.has(t.sourceId), - ) - - if (templatesToDelete.length > 0) { - await tx.delete(messengerMessageTemplateModel).where( - inArray( - messengerMessageTemplateModel.id, - templatesToDelete.map((t) => t.id), - ), - ) - } - } - - for (const template of templates) { - await tx - .insert(messengerMessageTemplateModel) - .values([ - { - id: createId(), - name: template.name, - integrationMessengerId: integrationMessenger.id, - language: template.language, - category: template.category, - status: template.status, - parameterFormat: template.parameter_format ?? "POSITIONAL", - sourceId: template.id, - components: template.components, - }, - ]) - .onConflictDoUpdate({ - target: [ - messengerMessageTemplateModel.integrationMessengerId, - messengerMessageTemplateModel.sourceId, - ], - set: { - name: template.name, - language: template.language, - category: template.category, - status: template.status, - parameterFormat: template.parameter_format ?? "POSITIONAL", - components: template.components, - }, - }) - } + await messengerMessageTemplateService.syncFromMeta({ + integrationMessengerId: integrationMessenger.id, + templates, + isPartialSync, }) } @@ -138,14 +79,14 @@ export const syncMessengerMessageTemplateAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationMessenger = await findOrFail({ - table: integrationMessengerModel, - where: { + const integrationMessenger = + await messengerIntegrationService.findByIdForWorkspace({ workspaceId, id, - }, - message: "Messenger integration not found", - }) + }) + if (!integrationMessenger) { + throw new Error("Messenger integration not found") + } await syncMessengerMessageTemplatesForIntegration({ workspaceId, diff --git a/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts b/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts index a7a2691a57..aa045ca036 100644 --- a/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts +++ b/apps/builder/src/features/integration-messenger/message-templates/queries/index.ts @@ -1,138 +1 @@ -import { - and, - type DatabaseClient, - db, - eq, - ilike, -} from "@chatbotx.io/database/client" -import type { MessengerTemplateStatus } from "@chatbotx.io/database/partials" -import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, -} from "@chatbotx.io/database/utils" -import type { ListMessengerMessageTemplatesResponse } from "@/features/integration-messenger/message-templates/schema/query" - -type MessengerMessageTemplateListWhere = { - workspaceId: string - inboxId?: string - integrationMessengerId?: string - status?: MessengerTemplateStatus - name?: string -} - -async function resolveIntegrationMessengerId({ - tx, - where, -}: { - tx: DatabaseClient - where: MessengerMessageTemplateListWhere -}) { - let resolvedIntegrationMessengerId = where.integrationMessengerId - - if (!resolvedIntegrationMessengerId && where.inboxId) { - const integration = await tx.query.integrationMessengerModel.findFirst({ - where: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - columns: { id: true }, - }) - resolvedIntegrationMessengerId = integration?.id - } - - return resolvedIntegrationMessengerId -} - -export const messengerMessageTemplateService = { - list: async (props: { - tx?: DatabaseClient - where: MessengerMessageTemplateListWhere - }): Promise => { - const { tx = db, where } = props - - // Resolve integrationMessengerId from inboxId when only inboxId is given. - // Relying on nested relational filtering for inboxId is fragile and ORM- - // version-sensitive because messengerMessageTemplateModel has no direct - // inboxId column. - const resolvedIntegrationMessengerId = await resolveIntegrationMessengerId({ - tx, - where, - }) - - return tx.query.messengerMessageTemplateModel.findMany({ - where: { - status: where.status, - integrationMessengerId: resolvedIntegrationMessengerId, - integrationMessenger: { - workspaceId: where.workspaceId, - }, - }, - with: { - integrationMessenger: true, - }, - orderBy: { id: "desc" }, - }) - }, - listPaginated: async (props: { - tx?: DatabaseClient - where: MessengerMessageTemplateListWhere - page?: number - perPage?: number - }) => { - const { tx = db, where } = props - const resolvedIntegrationMessengerId = await resolveIntegrationMessengerId({ - tx, - where, - }) - const queryWhere = { - name: where.name ? { ilike: likeContains(where.name) } : undefined, - status: where.status, - integrationMessengerId: resolvedIntegrationMessengerId, - integrationMessenger: { - workspaceId: where.workspaceId, - }, - } - const pagination = getPaginationWithDefaults({ - page: props.page, - perPage: props.perPage, - }) - - const [data, total] = await Promise.all([ - tx.query.messengerMessageTemplateModel.findMany({ - where: queryWhere, - with: { - integrationMessenger: true, - }, - orderBy: { id: "desc" }, - limit: pagination.limit, - offset: pagination.offset, - }), - tx.$count( - messengerMessageTemplateModel, - and( - where.name - ? ilike( - messengerMessageTemplateModel.name, - likeContains(where.name), - ) - : undefined, - where.status - ? eq(messengerMessageTemplateModel.status, where.status) - : undefined, - resolvedIntegrationMessengerId - ? eq( - messengerMessageTemplateModel.integrationMessengerId, - resolvedIntegrationMessengerId, - ) - : undefined, - ), - ), - ]) - - return { - data, - pageCount: Math.max(1, Math.ceil(total / pagination.limit)), - } - }, -} +export { messengerMessageTemplateService } from "@chatbotx.io/business" diff --git a/apps/builder/src/features/integration-messenger/queries/index.ts b/apps/builder/src/features/integration-messenger/queries/index.ts index 2de7df45df..6cdfa9bf49 100644 --- a/apps/builder/src/features/integration-messenger/queries/index.ts +++ b/apps/builder/src/features/integration-messenger/queries/index.ts @@ -1,4 +1,5 @@ -import { db, findOrFail } from "@chatbotx.io/database/client" +import { messengerIntegrationService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationMessengerModel } from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" @@ -10,12 +11,7 @@ export const findIntegrationMessenger = async ( export const listIntegrationMessengers = async ( input: Partial>, ): Promise<{ data: IntegrationMessengerModel[] }> => { - const data = await db.query.integrationMessengerModel.findMany({ - where: input, - orderBy: { - createdAt: "asc", - }, - }) + const data = await messengerIntegrationService.listByWorkspaceIdOrId(input) return { data } } diff --git a/apps/builder/src/features/integration-openai/actions/connect.action.ts b/apps/builder/src/features/integration-openai/actions/connect.action.ts index 839e936329..0bfab1111d 100644 --- a/apps/builder/src/features/integration-openai/actions/connect.action.ts +++ b/apps/builder/src/features/integration-openai/actions/connect.action.ts @@ -2,14 +2,7 @@ import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { - integrationModel, - integrationOpenaiModel, -} from "@chatbotx.io/database/schema" -import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" -import { createId } from "@chatbotx.io/utils" +import { integrationOpenAIService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -44,52 +37,12 @@ export const connectOpenAIAction = authActionClient }) } - const integrationOpenAI = await db.query.integrationOpenaiModel.findFirst( - { - where: { - workspaceId, - }, - }, - ) - - await db.transaction(async (tx) => { - if (integrationOpenAI) { - await tx - .update(integrationOpenaiModel) - .set({ - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - .where(eq(integrationOpenaiModel.id, integrationOpenAI.id)) - } else { - const integration = await tx - .insert(integrationModel) - .values({ - id: createId(), - workspaceId, - integrationType: "openai", - }) - .returning() - .then((result) => result[0]) - - await tx.insert(integrationOpenaiModel).values({ - id: createId(), - integrationId: integration.id, - workspaceId, - model: parsedInput.model, - auth: { - authType: AuthType.secretText, - secretText: parsedInput.apiKey, - } as SecretTextAuthValue, - temperature: parsedInput.temperature, - maxOutputTokens: parsedInput.maxOutputTokens, - }) - } + await integrationOpenAIService.connect({ + workspaceId, + apiKey: parsedInput.apiKey, + model: parsedInput.model, + temperature: parsedInput.temperature, + maxOutputTokens: parsedInput.maxOutputTokens, }) await aiIntegrationService.invalidateCache( @@ -97,14 +50,6 @@ export const connectOpenAIAction = authActionClient aiProviders.enum.openai, ) - await auditService.record({ - workspaceId, - action: integrationOpenAI ? "update" : "connect", - detail: integrationOpenAI - ? "updated the OpenAI integration configuration" - : "connected a new OpenAI integration", - }) - return }, ) diff --git a/apps/builder/src/features/integration-openai/actions/update-openai.action.ts b/apps/builder/src/features/integration-openai/actions/update-openai.action.ts index da229cc21c..e7230177d9 100644 --- a/apps/builder/src/features/integration-openai/actions/update-openai.action.ts +++ b/apps/builder/src/features/integration-openai/actions/update-openai.action.ts @@ -1,8 +1,6 @@ "use server" import { aiIntegrationService } from "@chatbotx.io/ai/server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationOpenaiModel } from "@chatbotx.io/database/schema" +import { integrationOpenAIService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" import { @@ -29,29 +27,9 @@ export const updateIntegrationOpenAI = async ( }, parsedInput: UpdateOpenAIRequest, ) => { - const integrationOpenAI = await findOrFail({ - table: integrationOpenaiModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Integration OpenAI not found", - }) - - const result = await db - .update(integrationOpenaiModel) - .set(parsedInput) - .where(eq(integrationOpenaiModel.id, integrationOpenAI.id)) - .returning() - .then((result) => result[0]) + const result = await integrationOpenAIService.update(ctx, parsedInput) await aiIntegrationService.invalidateCache(ctx.workspaceId, "openai") - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail: "updated the OpenAI integration configuration", - }) - return result } diff --git a/apps/builder/src/features/integration-openai/queries/index.ts b/apps/builder/src/features/integration-openai/queries/index.ts index d2d7527663..26d50b8980 100644 --- a/apps/builder/src/features/integration-openai/queries/index.ts +++ b/apps/builder/src/features/integration-openai/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { integrationOpenAIService } from "@chatbotx.io/business" import type { IntegrationOpenAIResource } from "../schema/request" export const findIntegrationOpenAI = async ({ @@ -8,11 +8,7 @@ export const findIntegrationOpenAI = async ({ }): Promise<{ data: IntegrationOpenAIResource | null }> => { - const data = await db.query.integrationOpenaiModel.findFirst({ - where: { - workspaceId, - }, - }) + const data = await integrationOpenAIService.findByWorkspaceId(workspaceId) return { data: data ?? null, diff --git a/apps/builder/src/features/integration-smtp/queries/index.ts b/apps/builder/src/features/integration-smtp/queries/index.ts index 6c04ed720e..1632258561 100644 --- a/apps/builder/src/features/integration-smtp/queries/index.ts +++ b/apps/builder/src/features/integration-smtp/queries/index.ts @@ -1,6 +1,7 @@ "use server" -import { db, findOrFail } from "@chatbotx.io/database/client" +import { integrationSmtpService } from "@chatbotx.io/business" +import { findOrFail } from "@chatbotx.io/database/client" import { integrationSmtpModel } from "@chatbotx.io/database/schema" import type { IntegrationSmtpModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -16,14 +17,7 @@ export const listIntegrationSmtps = async (input: { }): Promise<{ data: IntegrationSmtpResource[] }> => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const data = await db.query.integrationSmtpModel.findMany({ - where: { - workspaceId: input.workspaceId, - }, - orderBy: { - createdAt: "desc", - }, - }) + const data = await integrationSmtpService.listByWorkspaceId(input.workspaceId) return { data: data.map(({ id, name, fromAddress }) => ({ diff --git a/apps/builder/src/features/integration-smtp/services/smtp.service.ts b/apps/builder/src/features/integration-smtp/services/smtp.service.ts index c7da8375dc..d734c55507 100644 --- a/apps/builder/src/features/integration-smtp/services/smtp.service.ts +++ b/apps/builder/src/features/integration-smtp/services/smtp.service.ts @@ -1,17 +1,7 @@ -import { - connectChannelIntegration, - inboxService, - workspaceService, -} from "@chatbotx.io/business" -import { auditService, isSameJsonValue } from "@chatbotx.io/business/audit" +import { integrationSmtpService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { channelTypes } from "@chatbotx.io/database/partials" -import { integrationSmtpModel } from "@chatbotx.io/database/schema" -import type { SmtpAuthValue } from "@chatbotx.io/integration-smtp" import { smtpHostMap } from "@chatbotx.io/integration-smtp" import { createSmtpTransporter } from "@chatbotx.io/mail/transport" -import { createId } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import type { CreateSmtpRequest, UpdateSmtpRequest } from "../schema/mutation" @@ -39,65 +29,28 @@ export async function verifySmtpConnection(input: CreateSmtpRequest) { } } +const resolveHostAndPort = (input: { + provider: CreateSmtpRequest["provider"] + host: string + port: number +}) => { + if (input.provider !== "other") { + return smtpHostMap[input.provider] + } + return { host: input.host, port: input.port } +} + export async function createSmtp( workspaceId: string, input: CreateSmtpRequest, ) { - let { host, port, fromAddress, ...rest } = input await verifySmtpConnection(input) - - if (input.provider !== "other") { - const defaultHostAndPort = smtpHostMap[input.provider] - host = defaultHostAndPort.host - port = defaultHostAndPort.port - } - - const workspace = await workspaceService.find({ where: { id: workspaceId } }) - if (!workspace) { - throw new ChatbotXException("Workspace not found") - } - - const { inbox, wasCreated } = await db.transaction(async (tx) => { - const smtpId = createId() - const name = input.username - - return await connectChannelIntegration({ - tx, - ownerId: workspace.ownerId, - inboxData: { - id: smtpId, - workspaceId, - channel: channelTypes.enum.smtp, - name, - sourceId: smtpId, - }, - insertIntegration: async (inboxId) => { - await tx.insert(integrationSmtpModel).values({ - id: smtpId, - name, - workspaceId, - inboxId, - fromAddress, - auth: { - authType: "custom" as const, - ...rest, - host, - port, - }, - }) - }, - }) + const { host, port } = resolveHostAndPort(input) + return await integrationSmtpService.create(workspaceId, { + ...input, + host, + port, }) - - if (wasCreated) { - await auditService.record({ - workspaceId, - action: "connect", - detail: `connected a new SMTP channel (#${inbox.id})`, - }) - } - - return inbox } export async function updateSmtp( @@ -106,93 +59,14 @@ export async function updateSmtp( input: UpdateSmtpRequest, ) { await verifySmtpConnection(input) - - const integration = await findOrFail({ - table: integrationSmtpModel, - where: { id, workspaceId }, - message: "SMTP integration not found", - }) - - const currentAuth = integration.auth as SmtpAuthValue - const provider = input.provider ?? currentAuth.provider - - let host = input.host || currentAuth.host - let port = input.port || currentAuth.port - - if (provider !== "other") { - const defaults = smtpHostMap[provider] - host = defaults.host - port = defaults.port - } - - const updatedAuth: SmtpAuthValue = { - authType: "custom", - provider, + const { host, port } = resolveHostAndPort(input) + return await integrationSmtpService.update(workspaceId, id, { + ...input, host, port, - username: input.username ?? currentAuth.username, - password: input.password ?? currentAuth.password, - } - - const name = input.username ?? integration.name - - const updated = await db - .update(integrationSmtpModel) - .set({ auth: updatedAuth, name, fromAddress: input.fromAddress }) - .where(eq(integrationSmtpModel.id, integration.id)) - .returning() - .then((result) => result[0]) - - const hasChanged = !isSameJsonValue( - { auth: updatedAuth, name, fromAddress: input.fromAddress }, - { - auth: currentAuth, - name: integration.name, - fromAddress: integration.fromAddress, - }, - ) - - if (hasChanged) { - await auditService.record({ - workspaceId, - action: "update", - detail: "updated the SMTP channel configuration", - }) - } - - return updated + }) } export async function deleteSmtp(workspaceId: string, id: string) { - const [integration, workspace] = await Promise.all([ - findOrFail({ - table: integrationSmtpModel, - where: { - id, - workspaceId, - }, - message: "SMTP integration not found", - }), - workspaceService.findById({ id: workspaceId }), - ]) - - await db.transaction(async (tx) => { - await tx - .delete(integrationSmtpModel) - .where(eq(integrationSmtpModel.id, integration.id)) - - await inboxService.disconnect({ - inboxId: integration.inboxId, - ownerId: workspace.ownerId, - workspaceId, - reason: "manual", - tx, - }) - }) - - await auditService.record({ - workspaceId, - action: "disconnect", - detail: `disconnected the SMTP channel (#${integration.id})`, - }) + await integrationSmtpService.delete(workspaceId, id) } diff --git a/apps/builder/src/features/integration-telegram/actions/connect.action.ts b/apps/builder/src/features/integration-telegram/actions/connect.action.ts index dc017af571..6995829203 100644 --- a/apps/builder/src/features/integration-telegram/actions/connect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/connect.action.ts @@ -1,18 +1,14 @@ "use server" import { - connectChannelIntegration, + telegramIntegrationService, userQuotaService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" import { db, isDatabaseError } from "@chatbotx.io/database/client" -import { integrationTypes } from "@chatbotx.io/database/partials" -import { integrationTelegramModel } from "@chatbotx.io/database/schema" import type { UserModel } from "@chatbotx.io/database/types" -import type { TelegramAuthValue } from "@chatbotx.io/integration-telegram" -import { createId } from "@chatbotx.io/utils" import { redirect } from "next/navigation" import { isCloud } from "@/env" import { integrations } from "@/integration" @@ -67,10 +63,6 @@ export const connectTelegramAction = authActionClient } const result = await db.transaction(async (tx) => { - const auth: TelegramAuthValue = { - authType: "secretText", - secretText: parsedInput.botToken, - } let createdWorkspace = false if (!workspaceId) { @@ -87,28 +79,15 @@ export const connectTelegramAction = authActionClient createdWorkspace = true } - const integrationId = createId() - const { wasCreated } = await connectChannelIntegration({ - tx, - ownerId, - inboxData: { - id: createId(), + const { integrationId, wasCreated } = + await telegramIntegrationService.connect({ + tx, + ownerId, workspaceId: workspaceId as string, - name: botData.username, - channel: integrationTypes.enum.telegram, - sourceId: botData.id, - }, - insertIntegration: async (inboxId) => { - await tx.insert(integrationTelegramModel).values({ - id: integrationId, - inboxId, - workspaceId: workspaceId as string, - botId: botData.id, - name: botData.username, - auth, - }) - }, - }) + botId: botData.id, + botUsername: botData.username, + botToken: parsedInput.botToken, + }) // Register webhook URL with Telegram const webhookUrl = buildBrokerCallbackUrl( diff --git a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts index 0c36448084..84411433da 100644 --- a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts @@ -1,9 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + inboxService, + telegramIntegrationService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationTelegramModel } from "@chatbotx.io/database/schema" +import { db } from "@chatbotx.io/database/client" import type { TelegramAuthValue } from "@chatbotx.io/integration-telegram" import { type WorkspaceIdAndIdRequestParams, @@ -22,11 +25,7 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationTelegram, workspace] = await Promise.all([ - findOrFail({ - table: integrationTelegramModel, - where: { workspaceId, id }, - message: "Integration Telegram not found", - }), + telegramIntegrationService.findByWorkspaceIdAndId({ workspaceId, id }), workspaceService.findById({ id: workspaceId }), ]) @@ -42,9 +41,11 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired } await db.transaction(async (tx) => { - await tx - .delete(integrationTelegramModel) - .where(eq(integrationTelegramModel.id, integrationTelegram.id)) + await telegramIntegrationService.disconnect({ + workspaceId, + id: integrationTelegram.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationTelegram.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-telegram/queries/index.ts b/apps/builder/src/features/integration-telegram/queries/index.ts index ff314d853c..d6e9871dd5 100644 --- a/apps/builder/src/features/integration-telegram/queries/index.ts +++ b/apps/builder/src/features/integration-telegram/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { telegramIntegrationService } from "@chatbotx.io/business" import type { IntegrationTelegramModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -7,12 +7,7 @@ export const listIntegrationTelegrams = async ({ }: { where: Partial> }): Promise<{ data: IntegrationTelegramModel[] }> => { - const data = await db.query.integrationTelegramModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await telegramIntegrationService.listByWorkspaceId(where) return { data } } @@ -25,9 +20,7 @@ export const findIntegrationTelegram = async ({ await assertCurrentUserCanAccessChatbot(workspaceId) return ( - (await db.query.integrationTelegramModel.findFirst({ - where: { workspaceId }, - })) ?? null + (await telegramIntegrationService.findByWorkspaceId(workspaceId)) ?? null ) } @@ -37,6 +30,4 @@ export const findIntegrationTelegramByBotId = async ({ }: { botId: string }): Promise => - (await db.query.integrationTelegramModel.findFirst({ - where: { botId }, - })) ?? null + (await telegramIntegrationService.findByBotId(botId)) ?? null diff --git a/apps/builder/src/features/integration-tiktok/actions/connect.action.ts b/apps/builder/src/features/integration-tiktok/actions/connect.action.ts index d7b505017d..fedb3d3634 100644 --- a/apps/builder/src/features/integration-tiktok/actions/connect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/connect.action.ts @@ -1,14 +1,12 @@ import { - connectChannelIntegration, + tiktokIntegrationService, workspaceService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" import { db } from "@chatbotx.io/database/client" import type { TiktokCredential } from "@chatbotx.io/database/partials" -import { integrationTiktokModel } from "@chatbotx.io/database/schema" import type { TiktokAuthValue } from "@chatbotx.io/integration-tiktok" -import { createId } from "@chatbotx.io/utils" import { redirect } from "next/navigation" import { integrations } from "@/integration" import { getGuestClientIp } from "@/lib/rate-limit/guest-rate-limit" @@ -38,42 +36,17 @@ export async function connectTiktokHandler({ const displayName = authValue.metadata.displayName const { ownerId } = await workspaceService.findById({ id: workspaceId }) - const integrationId = createId() try { const { wasCreated, integration } = await db.transaction(async (tx) => - connectChannelIntegration({ + tiktokIntegrationService.connect({ tx, ownerId, - inboxData: { - workspaceId, - name: displayName, - channel: "tiktok", - sourceId: authValue.metadata.username, - }, - insertIntegration: async (inboxId) => { - const [integration] = await tx - .insert(integrationTiktokModel) - .values({ - id: integrationId, - inboxId, - workspaceId, - openId, - name: displayName, - auth: authValue, - }) - .onConflictDoUpdate({ - target: [integrationTiktokModel.openId], - set: { - auth: authValue, - name: displayName, - tokenRefreshError: null, - }, - }) - .returning({ id: integrationTiktokModel.id }) - - return integration - }, + workspaceId, + openId, + username: authValue.metadata.username, + displayName, + auth: authValue, }), ) diff --git a/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts b/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts index 961438f208..74e4316b52 100644 --- a/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-tiktok/actions/disconnect.action.ts @@ -1,9 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + inboxService, + tiktokIntegrationService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationTiktokModel } from "@chatbotx.io/database/schema" +import { db } from "@chatbotx.io/database/client" import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, @@ -19,18 +22,15 @@ export const disconnectTiktokAction = workspaceActionClientAllowExpired bindArgsParsedInputs: WorkspaceIdAndIdRequestParams }) => { const [integrationTiktok, workspace] = await Promise.all([ - findOrFail({ - table: integrationTiktokModel, - where: { workspaceId, id }, - message: "Integration TikTok not found", - }), + tiktokIntegrationService.findById({ id, workspaceId }), workspaceService.findById({ id: workspaceId }), ]) await db.transaction(async (tx) => { - await tx - .delete(integrationTiktokModel) - .where(eq(integrationTiktokModel.id, integrationTiktok.id)) + await tiktokIntegrationService.disconnect({ + id: integrationTiktok.id, + tx, + }) await inboxService.disconnect({ inboxId: integrationTiktok.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-tiktok/queries/index.ts b/apps/builder/src/features/integration-tiktok/queries/index.ts index 3bec9e2339..f64e376345 100644 --- a/apps/builder/src/features/integration-tiktok/queries/index.ts +++ b/apps/builder/src/features/integration-tiktok/queries/index.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { tiktokIntegrationService } from "@chatbotx.io/business" import type { IntegrationTiktokModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -7,12 +7,7 @@ export const listIntegrationTiktoks = async ({ }: { where: Partial> }): Promise<{ data: IntegrationTiktokModel[] }> => { - const data = await db.query.integrationTiktokModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await tiktokIntegrationService.listByWorkspaceId(where) return { data } } @@ -23,11 +18,7 @@ export const findIntegrationTiktok = async ({ }): Promise => { await assertCurrentUserCanAccessChatbot(workspaceId) - return ( - (await db.query.integrationTiktokModel.findFirst({ - where: { workspaceId }, - })) ?? null - ) + return (await tiktokIntegrationService.findByWorkspaceId(workspaceId)) ?? null } export const findIntegrationTiktokByOpenId = async ({ @@ -35,6 +26,4 @@ export const findIntegrationTiktokByOpenId = async ({ }: { openId: string }): Promise => - (await db.query.integrationTiktokModel.findFirst({ - where: { openId }, - })) ?? null + (await tiktokIntegrationService.findByOpenId(openId)) ?? null diff --git a/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts b/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts index 7a11767b61..6555bcd314 100644 --- a/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts +++ b/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts @@ -1,11 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" +import { + integrationWebchatService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ensureBrandingMenuEntry } from "@chatbotx.io/business/branding" import { db } from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" import { isCommunity } from "@/env" import { getTenantSettings } from "@/features/tenant/utils" import { authActionClient } from "@/lib/safe-action" @@ -51,30 +52,22 @@ export const createWebchatAction = authActionClient createdWorkspace = true } - const webchatId = createId() - const { inbox } = await inboxService.create({ - tx, - ownerId, - data: { - id: webchatId, + const created = await integrationWebchatService.create( + { workspaceId, - channel: "webchat", - name: rest.name, - sourceId: webchatId, + ownerId, + data: { + ...rest, + persistentMenus, + authorizedDomains: authorizedDomains.map((domain) => domain.value), + auth: {}, + customCss: rest.customCss ?? null, + }, }, - }) - - await tx.insert(integrationWebchatModel).values({ - ...rest, - persistentMenus, - id: webchatId, - authorizedDomains: authorizedDomains.map((domain) => domain.value), - workspaceId, - inboxId: inbox.id, - auth: {}, - }) + tx, + ) - return { workspaceId, createdWorkspace, webchatId } + return { workspaceId, createdWorkspace, webchatId: created.id } }) if (result.createdWorkspace) { diff --git a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts index 210cfda6db..bcf2b4feae 100644 --- a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts +++ b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts @@ -1,8 +1,7 @@ "use server" +import { integrationWebchatService } from "@chatbotx.io/business" import { ensureBrandingMenuEntry } from "@chatbotx.io/business/branding" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" import { zodBigintAsString } from "@chatbotx.io/utils" import { isCommunity } from "@/env" import { getTenantSettings } from "@/features/tenant/utils" @@ -32,15 +31,6 @@ export const updateWebchatAction = workspaceActionClient throw new Error("You need to be a super admin to update this webchat") } - const integration = await findOrFail({ - table: integrationWebchatModel, - where: { - id, - workspaceId, - }, - message: "Webchat integration not found", - }) - // Community keeps the "Built with" branding entry; silently re-add it // (same precedent as moveBrandingMenuLast in the messenger action). const persistentMenus = @@ -51,18 +41,15 @@ export const updateWebchatAction = workspaceActionClient }) : rest.persistentMenus - await db.transaction(async (tx) => { - await tx - .update(integrationWebchatModel) - .set({ - ...rest, - persistentMenus, - workspaceId, - welcomeFlowId: welcomeFlowId?.length ? welcomeFlowId : null, - authorizedDomains: authorizedDomains - ? authorizedDomains.map((domain) => domain.value) - : undefined, - }) - .where(eq(integrationWebchatModel.id, integration.id)) - }) + await integrationWebchatService.update( + { workspaceId, id }, + { + ...rest, + persistentMenus, + welcomeFlowId: welcomeFlowId?.length ? welcomeFlowId : null, + authorizedDomains: authorizedDomains + ? authorizedDomains.map((domain) => domain.value) + : undefined, + }, + ) }) diff --git a/apps/builder/src/features/integration-webchat/queries/index.ts b/apps/builder/src/features/integration-webchat/queries/index.ts index cc8736ed64..784e012a91 100644 --- a/apps/builder/src/features/integration-webchat/queries/index.ts +++ b/apps/builder/src/features/integration-webchat/queries/index.ts @@ -1,11 +1,6 @@ "use server" -import { - db, - findOrFail, - relationsFilterToSQL, -} from "@chatbotx.io/database/client" -import { integrationWebchatModel } from "@chatbotx.io/database/schema" +import { integrationWebchatService } from "@chatbotx.io/business" import type { IntegrationWebchatModel } from "@chatbotx.io/database/types" import { parsePagination } from "@chatbotx.io/database/utils" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -16,26 +11,11 @@ export const listIntegrationWebchats = async ( ) => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const where = { - workspaceId: input.workspaceId, - } - const pagination = parsePagination(input) - const [data, totalRows] = await Promise.all([ - db.query.integrationWebchatModel.findMany({ - where, - orderBy: { - createdAt: "desc", - }, - ...pagination, - }), - pagination?.limit - ? db.$count( - integrationWebchatModel, - relationsFilterToSQL(integrationWebchatModel, where), - ) - : Promise.resolve(1), - ]) + const [data, totalRows] = await integrationWebchatService.listByWorkspaceId({ + workspaceId: input.workspaceId, + pagination, + }) const pageCount = pagination?.limit ? Math.ceil(totalRows / pagination.limit) @@ -46,9 +26,5 @@ export const listIntegrationWebchats = async ( export async function findIntegrationWebchat( where: Pick, ) { - return await findOrFail({ - table: integrationWebchatModel, - where, - message: "Integration webchat not found", - }) + return await integrationWebchatService.findByWorkspaceIdAndId(where) } diff --git a/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts b/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts index 772d8570b0..176b1644b7 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/disconnect.action.ts @@ -1,19 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import type { DatabaseClient } from "@chatbotx.io/database/client" -import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { - LIVE_RUN_STATUSES, - metaCapiEventRepository, -} from "@chatbotx.io/database/repositories" import { - coexistSyncRunModel, - integrationWhatsappModel, - whatsappCoexistStagingModel, -} from "@chatbotx.io/database/schema" -import type { IntegrationWhatsappModel } from "@chatbotx.io/database/types" + integrationWhatsappService, + workspaceService, +} from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" +import { db, findOrFail } from "@chatbotx.io/database/client" +import { integrationWhatsappModel } from "@chatbotx.io/database/schema" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" import { isRevokedTokenError } from "@chatbotx.io/integration-whatsapp" import { @@ -23,78 +16,6 @@ import { import { integrations } from "@/integration" import { workspaceActionClientAllowExpired } from "@/lib/safe-action" -/** - * Everything one disconnect must abandon or delete, in a single transaction. - * - * Sync history (importedCount / lastSyncedAt / …) is deliberately preserved - * for audit and so a reconnect can resume from the prior watermark; only - * ACTIVE runs are abandoned so the scheduler stops trying to drive them - * forward against a now-missing integration. `LIVE_RUN_STATUSES` includes - * `waiting`: a WhatsApp coexist run parked for more Meta history must be - * abandoned here too, otherwise the scheduler cannot revive it (its staging - * rows are deleted below) and it lingers until the 24h history-window - * timeout closes it. - */ -async function purgeWhatsappIntegration( - tx: DatabaseClient, - { - integrationWhatsapp, - ownerId, - workspaceId, - }: { - integrationWhatsapp: IntegrationWhatsappModel - ownerId: string - workspaceId: string - }, -): Promise { - await tx - .update(coexistSyncRunModel) - .set({ - status: "failed", - finishedAt: new Date(), - currentError: "Integration disconnected", - }) - .where( - and( - eq(coexistSyncRunModel.integrationId, integrationWhatsapp.id), - inArray(coexistSyncRunModel.status, LIVE_RUN_STATUSES), - ), - ) - - await tx - .delete(whatsappCoexistStagingModel) - .where( - eq( - whatsappCoexistStagingModel.phoneNumberId, - integrationWhatsapp.phoneNumberId, - ), - ) - - // Polymorphic FK cleanup — no DB-level cascade for - // MetaCapiEvent.integrationId; stale rows would keep occupying the - // (workspaceId, channel, sourceKey) dedup slot after a reconnect. - await metaCapiEventRepository.deleteByIntegration( - { - workspaceId, - channel: "whatsapp", - integrationId: integrationWhatsapp.id, - }, - tx, - ) - - await tx - .delete(integrationWhatsappModel) - .where(eq(integrationWhatsappModel.id, integrationWhatsapp.id)) - - await inboxService.disconnect({ - inboxId: integrationWhatsapp.inboxId, - ownerId, - workspaceId, - reason: "manual", - tx, - }) -} - export const disconnectWhatsappAction = workspaceActionClientAllowExpired .bindArgsSchemas(workspaceIdAndIdRequestParams) .action( @@ -126,10 +47,11 @@ export const disconnectWhatsappAction = workspaceActionClientAllowExpired } await db.transaction((tx) => - purgeWhatsappIntegration(tx, { + integrationWhatsappService.disconnect({ integrationWhatsapp, ownerId: workspace.ownerId, workspaceId, + tx, }), ) diff --git a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts index 40f23c0e7e..8fb728c4b6 100644 --- a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts @@ -1,5 +1,4 @@ -import { findOrFail } from "@chatbotx.io/database/client" -import { integrationWhatsappModel } from "@chatbotx.io/database/schema" +import { integrationWhatsappService } from "@chatbotx.io/business" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" import { type ConversationalAutomation, @@ -13,14 +12,14 @@ export const findWhatsappAutomation = async ( ): Promise => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId: input.workspaceId, id: input.id, - }, - message: "Whatsapp integration not found", - }) + }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } return await findConversationalAutomation( integrationWhatsapp.auth as WhatsappAuthValue, diff --git a/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts b/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts index 2b8ace1049..b2f5a03c0a 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/actions/sync-whatsapp-flows.ts @@ -1,13 +1,12 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" import { - integrationWhatsappModel, - whatsappFlowModel, -} from "@chatbotx.io/database/schema" + buildContext, + integrationWhatsappService, + whatsappFlowService, +} from "@chatbotx.io/business" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,14 +17,11 @@ export const syncWhatsappFlowsAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { - workspaceId, - id, - }, - message: "Whatsapp integration not found", - }) + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -41,59 +37,8 @@ export const syncWhatsappFlowsAction = workspaceActionClient params: { limit: 100 }, }) - await db.transaction(async (tx) => { - const existingFlows = await tx - .select({ - id: whatsappFlowModel.id, - sourceId: whatsappFlowModel.sourceId, - }) - .from(whatsappFlowModel) - .where( - eq(whatsappFlowModel.integrationWhatsappId, integrationWhatsapp.id), - ) - - const incomingSourceIds = new Set(res.data.map((f) => f.id)) - - const flowsToDelete = existingFlows.filter( - (f) => !incomingSourceIds.has(f.sourceId), - ) - - if (flowsToDelete.length > 0) { - await tx.delete(whatsappFlowModel).where( - inArray( - whatsappFlowModel.id, - flowsToDelete.map((f) => f.id), - ), - ) - } - - for (const flow of res.data) { - const existing = existingFlows.find((f) => f.sourceId === flow.id) - - if (existing) { - await tx - .update(whatsappFlowModel) - .set({ - name: flow.name, - status: flow.status, - categories: flow.categories, - validationErrors: flow.validation_errors, - }) - .where(eq(whatsappFlowModel.id, existing.id)) - } else { - await tx.insert(whatsappFlowModel).values([ - { - id: createId(), - name: flow.name, - integrationWhatsappId: integrationWhatsapp.id, - sourceId: flow.id, - status: flow.status, - categories: flow.categories, - validationErrors: flow.validation_errors, - completedCount: "0", - }, - ]) - } - } + await whatsappFlowService.syncFromMeta({ + integrationWhatsappId: integrationWhatsapp.id, + flows: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/flows/api/private.ts b/apps/builder/src/features/integration-whatsapp/flows/api/private.ts index c9fd859a78..b4d381f53b 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/api/private.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/api/private.ts @@ -1,14 +1,12 @@ -import { buildContext } from "@chatbotx.io/business" -import { findOrFail } from "@chatbotx.io/database/client" import { - integrationWhatsappModel, - whatsappFlowModel, -} from "@chatbotx.io/database/schema" + buildContext, + integrationWhatsappService, + whatsappFlowService, +} from "@chatbotx.io/business" import { type WhatsappAuthValue, integration as whatsappIntegration, } from "@chatbotx.io/integration-whatsapp" -import { whatsappFlowService } from "@/features/integration-whatsapp/flows/queries" import { workspaceAuthorizedMidddleware } from "@/middlewares/auth" import { authorizedAPI } from "@/orpc" import { @@ -44,20 +42,16 @@ export const whatsappFlowInternalAPIs = { .use(workspaceAuthorizedMidddleware, (input) => input.workspaceId) .output(getWhatsappFlowScreensResponse) .handler(async ({ input }) => { - const flow = await findOrFail({ - table: whatsappFlowModel, - where: { id: input.flowId }, - message: "Whatsapp flow not found", - }) + const flow = await whatsappFlowService.findByIdUnscoped(input.flowId) - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ id: flow.integrationWhatsappId, workspaceId: input.workspaceId, - }, - message: "Whatsapp integration not found", - }) + }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId: input.workspaceId, diff --git a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts index fc3af2128e..eb12acf8e8 100644 --- a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts @@ -1,31 +1 @@ -import { type DatabaseClient, db } from "@chatbotx.io/database/client" -import type { ListWhatsappFlowsResponse } from "@/features/integration-whatsapp/flows/schema/query" - -export const whatsappFlowService = { - list: (props: { - tx?: DatabaseClient - where: { - workspaceId: string - inboxId?: string - integrationWhatsappId?: string - } - }): Promise => { - const { tx = db, where } = props - - const queryWhere = { - integrationWhatsappId: where.integrationWhatsappId, - integrationWhatsapp: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - } - - return tx.query.whatsappFlowModel.findMany({ - where: queryWhere, - with: { - integrationWhatsapp: true, - }, - orderBy: { createdAt: "asc" }, - }) - }, -} +export { whatsappFlowService } from "@chatbotx.io/business" diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts index dcc2ba6e90..301c2f22cc 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts @@ -1,13 +1,12 @@ "use server" -import { buildContext } from "@chatbotx.io/business" -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" import { - integrationWhatsappModel, - whatsappMessageTemplateModel, -} from "@chatbotx.io/database/schema" + buildContext, + integrationWhatsappService, + whatsappMessageTemplateService, +} from "@chatbotx.io/business" import type { WhatsappAuthValue } from "@chatbotx.io/integration-whatsapp" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { integrations } from "@/integration" import { workspaceActionClient } from "@/lib/safe-action" @@ -18,14 +17,11 @@ export const syncMessageTemplateAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - const integrationWhatsapp = await findOrFail({ - table: integrationWhatsappModel, - where: { - workspaceId, - id, - }, - message: "Whatsapp integration not found", - }) + const integrationWhatsapp = + await integrationWhatsappService.findByIdForWorkspace({ workspaceId, id }) + if (!integrationWhatsapp) { + throw new Error("Whatsapp integration not found") + } const ctx = await buildContext({ workspaceId, @@ -39,65 +35,8 @@ export const syncMessageTemplateAction = workspaceActionClient ctx, }) - await db.transaction(async (tx) => { - const existingTemplates = await tx - .select({ - id: whatsappMessageTemplateModel.id, - sourceId: whatsappMessageTemplateModel.sourceId, - }) - .from(whatsappMessageTemplateModel) - .where( - eq( - whatsappMessageTemplateModel.integrationWhatsappId, - integrationWhatsapp.id, - ), - ) - - const incomingSourceIds = new Set(res.data.map((t) => t.id)) - - const templatesToDelete = existingTemplates.filter( - (t) => !incomingSourceIds.has(t.sourceId), - ) - - if (templatesToDelete.length > 0) { - await tx.delete(whatsappMessageTemplateModel).where( - inArray( - whatsappMessageTemplateModel.id, - templatesToDelete.map((t) => t.id), - ), - ) - } - - for (const template of res.data) { - const existing = existingTemplates.find( - (t) => t.sourceId === template.id, - ) - - if (existing) { - await tx - .update(whatsappMessageTemplateModel) - .set({ - name: template.name, - language: template.language, - category: template.category, - status: template.status, - components: template.components, - }) - .where(eq(whatsappMessageTemplateModel.id, existing.id)) - } else { - await tx.insert(whatsappMessageTemplateModel).values([ - { - id: createId(), - name: template.name, - integrationWhatsappId: integrationWhatsapp.id, - language: template.language, - category: template.category, - status: template.status, - sourceId: template.id, - components: template.components, - }, - ]) - } - } + await whatsappMessageTemplateService.syncFromMeta({ + integrationWhatsappId: integrationWhatsapp.id, + templates: res.data, }) }) diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts b/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts index 38f8467fc2..8d08d9de9f 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/queries/index.ts @@ -1,33 +1 @@ -import { type DatabaseClient, db } from "@chatbotx.io/database/client" -import type { WhatsappTemplateStatus } from "@chatbotx.io/database/partials" -import type { ListWhatsappMessageTemplatesResponse } from "@/features/integration-whatsapp/message-templates/schema/query" - -export const whatsappMessageTemplateService = { - list: (props: { - tx?: DatabaseClient - where: { - workspaceId: string - inboxId?: string - integrationWhatsappId?: string - status?: WhatsappTemplateStatus - } - }): Promise => { - const { tx = db, where } = props - - const queryWhere = { - integrationWhatsappId: where.integrationWhatsappId, - integrationWhatsapp: { - workspaceId: where.workspaceId, - inboxId: where.inboxId, - }, - } - - return tx.query.whatsappMessageTemplateModel.findMany({ - where: queryWhere, - with: { - integrationWhatsapp: true, - }, - orderBy: { createdAt: "asc" }, - }) - }, -} +export { whatsappMessageTemplateService } from "@chatbotx.io/business" diff --git a/apps/builder/src/features/integration-whatsapp/queries/index.ts b/apps/builder/src/features/integration-whatsapp/queries/index.ts index 35c331e41c..75ec95d19b 100644 --- a/apps/builder/src/features/integration-whatsapp/queries/index.ts +++ b/apps/builder/src/features/integration-whatsapp/queries/index.ts @@ -1,5 +1,8 @@ -import type { IntegrationWhatsappResource } from "@chatbotx.io/business" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { + type IntegrationWhatsappResource, + integrationWhatsappService, +} from "@chatbotx.io/business" +import { db, findOrFail } from "@chatbotx.io/database/client" import { integrationWhatsappModel } from "@chatbotx.io/database/schema" import type { InboxModel, @@ -109,8 +112,5 @@ export const markWhatsappWebhookVerified = async ( }, } - await db - .update(integrationWhatsappModel) - .set({ auth: updatedAuth }) - .where(eq(integrationWhatsappModel.id, id)) + await integrationWhatsappService.markWebhookVerified(id, updatedAuth) } diff --git a/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts b/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts index 595c7b1955..0cc05fc297 100644 --- a/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts +++ b/apps/builder/src/features/integration-zalo/actions/__tests__/toggle-tag-sync.test.ts @@ -37,39 +37,17 @@ vi.mock("@/features/workspace-members/queries", () => ({ })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/client -// Zalo action does NOT call .returning(), so the chainable builder only needs -// update → set → where. +// Mock @chatbotx.io/database/client — findOrFail is still reached by the +// workspaceActionClient auth chain, even though the action itself no longer +// calls `db` directly. // --------------------------------------------------------------------------- -const dbUpdateBuilder = { - set: vi.fn(), - where: vi.fn(), -} - vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: vi.fn(), - }, findOrFail: vi.fn(), isDatabaseError: vi.fn(() => false), - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, -})) - -// --------------------------------------------------------------------------- -// Mock @chatbotx.io/database/schema -// --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/schema", () => ({ - integrationZaloModel: { - id: "id", - workspaceId: "workspaceId", - syncTagEnabledAt: "syncTagEnabledAt", - }, - userModel: { id: "id" }, })) // --------------------------------------------------------------------------- -// Mock @chatbotx.io/business (isPlatformAdmin) and errors +// Mock @chatbotx.io/business (isPlatformAdmin, zaloIntegrationService) and errors // // This factory mock enumerates exports, so it must cover everything // `workspaceActionClient` reaches — not just what this action calls directly. @@ -78,6 +56,8 @@ vi.mock("@chatbotx.io/database/schema", () => ({ // unrelated failure. `isWorkspaceScheduledForDeletion` is the deletion gate in // `lib/safe-action.ts`; `false` = an active workspace, this action's precondition. // --------------------------------------------------------------------------- +const updateTagSync = vi.fn() + vi.mock("@chatbotx.io/business", () => ({ isPlatformAdmin: vi.fn(async () => false), isWorkspaceScheduledForDeletion: vi.fn(() => false), @@ -91,6 +71,7 @@ vi.mock("@chatbotx.io/business", () => ({ isSupportSession: false, } }), + zaloIntegrationService: { updateTagSync }, })) vi.mock("@chatbotx.io/business/audit", () => ({ @@ -123,7 +104,7 @@ vi.mock("@/lib/log", () => ({ // --------------------------------------------------------------------------- const { toggleZaloTagSyncAction } = await import("../toggle-tag-sync.action") const { invalidateCacheByTags } = await import("@chatbotx.io/redis") -const { db, findOrFail } = await import("@chatbotx.io/database/client") +const { findOrFail } = await import("@chatbotx.io/database/client") const { getCurrentUserId } = await import("@/lib/auth/utils") const { getAllWorkspaceMembers } = await import( "@/features/workspace-members/queries" @@ -132,7 +113,6 @@ const { getAllWorkspaceMembers } = await import( const invalidateCacheByTagsMock = invalidateCacheByTags as ReturnType< typeof vi.fn > -const dbUpdate = db.update as ReturnType const findOrFailMock = findOrFail as ReturnType const getCurrentUserIdMock = getCurrentUserId as ReturnType const getAllWorkspaceMembersMock = getAllWorkspaceMembers as ReturnType< @@ -165,36 +145,21 @@ describe("toggleZaloTagSyncAction", () => { workspaceIds: [WORKSPACE_ID], }) - // Re-wire the chainable DB builder (Zalo action: update().set().where()) - dbUpdateBuilder.set.mockReturnValue(dbUpdateBuilder) - // where() must resolve to a promise since the action awaits the chain - dbUpdateBuilder.where.mockResolvedValue(undefined) - dbUpdate.mockReturnValue(dbUpdateBuilder) + updateTagSync.mockResolvedValue(undefined) }) // ── enabled: true ────────────────────────────────────────────────────────── describe("enabled: true", () => { - test("sets syncTagEnabledAt to a Date instance (not null)", async () => { - await invokeAction(true) - - expect(dbUpdate).toHaveBeenCalledTimes(1) - - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeInstanceOf(Date) - expect(setArg.syncTagEnabledAt).not.toBeNull() - }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { + test("calls zaloIntegrationService.updateTagSync with a truthy enabled flag", async () => { await invokeAction(true) - expect(dbUpdateBuilder.where).toHaveBeenCalledTimes(1) - // and() mock returns [...args], so the array has two eq() calls - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) + expect(updateTagSync).toHaveBeenCalledTimes(1) + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: true, + }) }) test("calls invalidateCacheByTags with the workspace-scoped zalo key", async () => { @@ -210,13 +175,14 @@ describe("toggleZaloTagSyncAction", () => { // ── enabled: false ───────────────────────────────────────────────────────── describe("enabled: false", () => { - test("sets syncTagEnabledAt to null", async () => { + test("calls zaloIntegrationService.updateTagSync with a falsy enabled flag", async () => { await invokeAction(false) - const setArg = dbUpdateBuilder.set.mock.calls[0]?.[0] as { - syncTagEnabledAt: unknown - } - expect(setArg.syncTagEnabledAt).toBeNull() + expect(updateTagSync).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + integrationId: INTEGRATION_ID, + enabled: false, + }) }) test("calls invalidateCacheByTags with the workspace-scoped zalo key", async () => { @@ -226,25 +192,15 @@ describe("toggleZaloTagSyncAction", () => { `workspaces:${WORKSPACE_ID}#zalos`, ]) }) - - test("scopes the WHERE clause by both workspaceId and integrationId", async () => { - await invokeAction(false) - - const whereArg = dbUpdateBuilder.where.mock.calls[0]?.[0] as unknown[] - expect(Array.isArray(whereArg)).toBe(true) - expect(whereArg).toHaveLength(2) - }) }) // ── no matching row (no-op) ──────────────────────────────────────────────── - // Zalo action does NOT use .returning() — it returns void. - // When no row matches, the update is a no-op at DB level; the action still - // completes without throwing. + // The service's update is a no-op at DB level when no row matches; the + // action still completes without throwing. describe("no matching row (no-op)", () => { test("returns void (undefined data) without throwing", async () => { - // where() resolves to undefined (no rows affected) — action returns void - dbUpdateBuilder.where.mockResolvedValue(undefined) + updateTagSync.mockResolvedValue(undefined) const result = await invokeAction(true) @@ -253,7 +209,7 @@ describe("toggleZaloTagSyncAction", () => { }) test("still calls invalidateCacheByTags even when no row was updated", async () => { - dbUpdateBuilder.where.mockResolvedValue(undefined) + updateTagSync.mockResolvedValue(undefined) await invokeAction(false) diff --git a/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts b/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts index 5b409839d6..b2cfd1be95 100644 --- a/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/connect-zalo.action.ts @@ -1,7 +1,7 @@ import { - connectChannelIntegration, tagSyncService, workspaceService, + zaloIntegrationService, } from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" @@ -10,7 +10,6 @@ import { channelTypes, type ZaloCredential, } from "@chatbotx.io/database/partials" -import { integrationZaloModel } from "@chatbotx.io/database/schema" import type { ZaloAuthValue } from "@chatbotx.io/integration-zalo" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { redirect } from "next/navigation" @@ -46,37 +45,21 @@ export async function connectZaloHandler({ let connectedIntegrationId: string | undefined let channelWasCreated = false + let wasDuplicate = false try { await db.transaction(async (tx) => { - const { wasCreated } = await connectChannelIntegration({ - tx, - ownerId, - inboxData: { + const { integrationId, wasCreated } = + await zaloIntegrationService.connect({ + tx, + ownerId, workspaceId, - name: authValue.metadata.oaName, - channel: "zalo", - sourceId: authValue.oaId, - }, - insertIntegration: async (inboxId, insertWasCreated) => { - if (!insertWasCreated) { - redirect( - `/space/${workspaceId}/settings/channels?channel=zalo&error=duplicated`, - ) - } - const [row] = await tx - .insert(integrationZaloModel) - .values({ - inboxId, - workspaceId, - oaId: authValue.oaId, - auth: authValue, - name: authValue.metadata.oaName, - }) - .returning({ id: integrationZaloModel.id }) - connectedIntegrationId = row?.id - }, - }) + oaId: authValue.oaId, + oaName: authValue.metadata.oaName, + auth: authValue, + }) + connectedIntegrationId = integrationId channelWasCreated = wasCreated + wasDuplicate = !integrationId }) } catch (error) { if ( @@ -90,6 +73,12 @@ export async function connectZaloHandler({ throw error } + if (wasDuplicate) { + redirect( + `/space/${workspaceId}/settings/channels?channel=zalo&error=duplicated`, + ) + } + if (channelWasCreated) { await auditService.record({ userId, diff --git a/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts b/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts index 6eab945d64..6afc571014 100644 --- a/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/disconnect.action.ts @@ -1,13 +1,12 @@ "use server" -import { inboxService, workspaceService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, findOrFail } from "@chatbotx.io/database/client" -import { channelTypes } from "@chatbotx.io/database/partials" import { - integrationZaloModel, - tagChannelModel, -} from "@chatbotx.io/database/schema" + inboxService, + workspaceService, + zaloIntegrationService, +} from "@chatbotx.io/business" +import { auditService } from "@chatbotx.io/business/audit" +import { db } from "@chatbotx.io/database/client" import { isRevokedTokenError, type ZaloAuthValue, @@ -24,14 +23,7 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired bindArgsParsedInputs: [workspaceId, id], } = props const [integrationZalo, workspace] = await Promise.all([ - findOrFail({ - table: integrationZaloModel, - where: { - workspaceId, - id, - }, - message: "Integration Zalo OA not found", - }), + zaloIntegrationService.findById({ id, workspaceId }), workspaceService.findById({ id: workspaceId }), ]) @@ -49,18 +41,7 @@ export const disconnectZaloAction = workspaceActionClientAllowExpired } await db.transaction(async (tx) => { - // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId - await tx - .delete(tagChannelModel) - .where( - and( - eq(tagChannelModel.channelType, channelTypes.enum.zalo), - eq(tagChannelModel.integrationId, integrationZalo.id), - ), - ) - await tx - .delete(integrationZaloModel) - .where(eq(integrationZaloModel.id, integrationZalo.id)) + await zaloIntegrationService.disconnect({ id: integrationZalo.id, tx }) await inboxService.disconnect({ inboxId: integrationZalo.inboxId, ownerId: workspace.ownerId, diff --git a/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts b/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts index 7af10723a2..0e03eebd31 100644 --- a/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts +++ b/apps/builder/src/features/integration-zalo/actions/toggle-tag-sync.action.ts @@ -1,7 +1,6 @@ "use server" -import { and, db, eq } from "@chatbotx.io/database/client" -import { integrationZaloModel } from "@chatbotx.io/database/schema" +import { zaloIntegrationService } from "@chatbotx.io/business" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -16,15 +15,11 @@ export const toggleZaloTagSyncAction = workspaceActionClient parsedInput: { enabled }, } = props - await db - .update(integrationZaloModel) - .set({ syncTagEnabledAt: enabled ? new Date() : null }) - .where( - and( - eq(integrationZaloModel.id, integrationId), - eq(integrationZaloModel.workspaceId, workspaceId), - ), - ) + await zaloIntegrationService.updateTagSync({ + workspaceId, + integrationId, + enabled, + }) await invalidateCacheByTags([`workspaces:${workspaceId}#zalos`]) }) diff --git a/apps/builder/src/features/integration-zalo/queries/index.ts b/apps/builder/src/features/integration-zalo/queries/index.ts index 06ce8e7f81..8d3271fd5c 100644 --- a/apps/builder/src/features/integration-zalo/queries/index.ts +++ b/apps/builder/src/features/integration-zalo/queries/index.ts @@ -1,5 +1,7 @@ -import type { IntegrationZaloResource } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" +import { + type IntegrationZaloResource, + zaloIntegrationService, +} from "@chatbotx.io/business" import type { IntegrationZaloModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" @@ -10,13 +12,7 @@ export const findIntegrationZalo = async ({ }): Promise => { await assertCurrentUserCanAccessChatbot(workspaceId) - return ( - (await db.query.integrationZaloModel.findFirst({ - where: { - workspaceId, - }, - })) ?? null - ) + return (await zaloIntegrationService.findByWorkspaceId(workspaceId)) ?? null } export const listIntegrationZalo = async ({ @@ -24,12 +20,7 @@ export const listIntegrationZalo = async ({ }: { where: Partial> }): Promise<{ data: IntegrationZaloModel[] }> => { - const data = await db.query.integrationZaloModel.findMany({ - where, - orderBy: { - createdAt: "asc", - }, - }) + const data = await zaloIntegrationService.listByWorkspaceId(where) return { data } } diff --git a/apps/builder/src/features/integrations/queries/get-ai-integrations.ts b/apps/builder/src/features/integrations/queries/get-ai-integrations.ts index 41e02fff4c..3a8d87038f 100644 --- a/apps/builder/src/features/integrations/queries/get-ai-integrations.ts +++ b/apps/builder/src/features/integrations/queries/get-ai-integrations.ts @@ -1,5 +1,5 @@ import { aiProviders } from "@chatbotx.io/ai" -import { db } from "@chatbotx.io/database/client" +import { integrationService } from "@chatbotx.io/business" type ListAIIntegrationsProps = { where: { @@ -8,25 +8,15 @@ type ListAIIntegrationsProps = { } export async function listAIIntegrations(props: ListAIIntegrationsProps) { - return await db.query.integrationModel.findMany({ - where: { - integrationType: { - in: [...aiProviders.options], - }, - workspaceId: props.where.workspaceId, - }, + return await integrationService.listByWorkspaceIdAndTypes({ + workspaceId: props.where.workspaceId, + integrationTypes: [...aiProviders.options], }) } export async function hasAIIntegration(workspaceId: string): Promise { - const exists = await db.query.integrationModel.findFirst({ - where: { - integrationType: { - in: [...aiProviders.options], - }, - workspaceId, - }, + return await integrationService.existsByWorkspaceIdAndTypes({ + workspaceId, + integrationTypes: [...aiProviders.options], }) - - return !!exists } diff --git a/packages/business/__tests__/integration-inbox-lookup.test.ts b/packages/business/__tests__/integration-inbox-lookup.test.ts index c357e42d60..1253e58115 100644 --- a/packages/business/__tests__/integration-inbox-lookup.test.ts +++ b/packages/business/__tests__/integration-inbox-lookup.test.ts @@ -14,11 +14,27 @@ const findOrFailMock = vi.fn() vi.mock("@chatbotx.io/database/client", () => ({ findOrFail: findOrFailMock, + db: {}, + eq: vi.fn(), + and: vi.fn(), })) vi.mock("@chatbotx.io/database/schema", () => ({ integrationZaloModel: { __table: "IntegrationZalo" }, integrationTelegramModel: { __table: "IntegrationTelegram" }, + tagChannelModel: { __table: "TagChannel" }, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { zalo: "zalo", telegram: "telegram" } }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/inbox/connect-channel", () => ({ + connectChannelIntegration: vi.fn(), })) beforeEach(() => { diff --git a/packages/business/__tests__/messenger-integration.service.connect-page.test.ts b/packages/business/__tests__/messenger-integration.service.connect-page.test.ts index 18766d87e1..cc0f273dc5 100644 --- a/packages/business/__tests__/messenger-integration.service.connect-page.test.ts +++ b/packages/business/__tests__/messenger-integration.service.connect-page.test.ts @@ -39,6 +39,14 @@ vi.mock("@chatbotx.io/database/schema", () => ({ integrationMessengerModel: { pageId: "pageId", }, + tagChannelModel: { + channelType: "channelType", + integrationId: "integrationId", + }, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { messenger: "messenger" } }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index 0c1c6f87e8..c2776573bf 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -68,6 +68,7 @@ export * from "./message" export * from "./message-cleanup" export * from "./messaging-ads" export * from "./messaging-ads-connection" +export * from "./messenger-message-template" export * from "./meta-catalog" export * from "./meta-conversions" export * from "./net" @@ -94,7 +95,9 @@ export * from "./types" export * from "./user" export * from "./user-quota" export * from "./webhook" +export * from "./whatsapp-flow" export * from "./whatsapp-flow-response" +export * from "./whatsapp-message-template" export * from "./workspace" export * from "./workspace-api-token" export * from "./workspace-lifecycle" diff --git a/packages/business/src/integration-ai-provider/connect.ts b/packages/business/src/integration-ai-provider/connect.ts new file mode 100644 index 0000000000..45fa6d78ba --- /dev/null +++ b/packages/business/src/integration-ai-provider/connect.ts @@ -0,0 +1,87 @@ +import { db, eq } from "@chatbotx.io/database/client" +import { integrationModel } from "@chatbotx.io/database/schema" +import { AuthType, type SecretTextAuthValue } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" +import type { AnyPgTable } from "drizzle-orm/pg-core" + +type AiProviderTable = AnyPgTable & { + id: unknown + integrationId: unknown + workspaceId: unknown +} + +export type ConnectAiProviderInput = { + workspaceId: string + apiKey: string + model: string + temperature: number + maxOutputTokens: number +} + +/** + * Shared connect logic for the claude/deepseek/gemini/openai tables — they are + * structurally identical (auth/model/temperature/maxOutputTokens/workspaceId/ + * integrationId), so a single upsert-by-workspace routine avoids four + * copy-paste clones. Callers pass their own table + integrationType; extra + * per-provider columns (e.g. openai's autoReplyVoice) are layered on by the + * caller via `extraInsertValues`/`extraUpdateValues`. + * + * The insert/update value objects are cast to `never` rather than the table + * itself — dispatching a single write across four structurally-similar but + * not statically-unifiable table types is the deliberate tradeoff of this + * consolidation; each table's own schema still enforces shape at the DB + * layer, and this function only ever writes the columns all four share. + */ +export async function connectAiProviderIntegration(props: { + table: AiProviderTable + integrationType: string + input: ConnectAiProviderInput + existing: { id: string } | null | undefined + extraInsertValues?: Record + extraUpdateValues?: Record +}) { + const auth: SecretTextAuthValue = { + authType: AuthType.secretText, + secretText: props.input.apiKey, + } + + await db.transaction(async (tx) => { + if (props.existing) { + await tx + .update(props.table) + .set({ + model: props.input.model, + auth, + temperature: props.input.temperature, + maxOutputTokens: props.input.maxOutputTokens, + ...props.extraUpdateValues, + } as never) + .where(eq(props.table.id as never, props.existing.id)) + return + } + + const [integration] = await tx + .insert(integrationModel) + .values({ + id: createId(), + workspaceId: props.input.workspaceId, + integrationType: props.integrationType, + }) + .returning() + + if (!integration) { + throw new Error("Failed to create integration record") + } + + await tx.insert(props.table).values({ + id: createId(), + integrationId: integration.id, + workspaceId: props.input.workspaceId, + model: props.input.model, + auth, + temperature: props.input.temperature, + maxOutputTokens: props.input.maxOutputTokens, + ...props.extraInsertValues, + } as never) + }) +} diff --git a/packages/business/src/integration-claude/service.ts b/packages/business/src/integration-claude/service.ts index 162a8abc4a..2945f333c8 100644 --- a/packages/business/src/integration-claude/service.ts +++ b/packages/business/src/integration-claude/service.ts @@ -1,12 +1,55 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationClaudeModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateClaudeInput = { autoReply?: boolean } class IntegrationClaudeService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationClaudeModel.findFirst({ where: { workspaceId } }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationClaudeModel, + integrationType: "claude", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the Claude integration configuration" + : "connected a new Claude integration", + ) + } + + async update(workspaceId: string, data: UpdateClaudeInput) { + const existing = await db.query.integrationClaudeModel.findFirst({ + where: { workspaceId }, + }) + if (!existing) { + throw new Error("Integration Claude not found") + } + + await db + .update(integrationClaudeModel) + .set(data) + .where(eq(integrationClaudeModel.id, existing.id)) + + await this.audit("update", "updated the Claude integration configuration") + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-deepseek/service.ts b/packages/business/src/integration-deepseek/service.ts index 7dae897594..95e9655eb0 100644 --- a/packages/business/src/integration-deepseek/service.ts +++ b/packages/business/src/integration-deepseek/service.ts @@ -1,6 +1,15 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationDeepseekModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateDeepSeekInput = { autoReply?: boolean } class IntegrationDeepSeekService extends BaseService { findByWorkspaceId(workspaceId: string) { @@ -9,6 +18,40 @@ class IntegrationDeepSeekService extends BaseService { }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationDeepseekModel, + integrationType: "deepseek", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the DeepSeek integration configuration" + : "connected a new DeepSeek integration", + ) + } + + async update(workspaceId: string, data: UpdateDeepSeekInput) { + const existing = await db.query.integrationDeepseekModel.findFirst({ + where: { workspaceId }, + }) + if (!existing) { + throw new Error("Integration DeepSeek not found") + } + + await db + .update(integrationDeepseekModel) + .set(data) + .where(eq(integrationDeepseekModel.id, existing.id)) + + await this.audit("update", "updated the DeepSeek integration configuration") + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-gemini/service.ts b/packages/business/src/integration-gemini/service.ts index 2361c5e0e9..d0b025259f 100644 --- a/packages/business/src/integration-gemini/service.ts +++ b/packages/business/src/integration-gemini/service.ts @@ -1,12 +1,55 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationGeminiModel, + integrationModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateGeminiInput = { autoReply?: boolean } class IntegrationGeminiService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationGeminiModel.findFirst({ where: { workspaceId } }) } + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationGeminiModel, + integrationType: "gemini", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the Gemini integration configuration" + : "connected a new Gemini integration", + ) + } + + async update(workspaceId: string, data: UpdateGeminiInput) { + const existing = await db.query.integrationGeminiModel.findFirst({ + where: { workspaceId }, + }) + if (!existing) { + throw new Error("Integration Gemini not found") + } + + await db + .update(integrationGeminiModel) + .set(data) + .where(eq(integrationGeminiModel.id, existing.id)) + + await this.audit("update", "updated the Gemini integration configuration") + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-google-sheet/service.ts b/packages/business/src/integration-google-sheet/service.ts index a8d6a1d721..4faebe00a3 100644 --- a/packages/business/src/integration-google-sheet/service.ts +++ b/packages/business/src/integration-google-sheet/service.ts @@ -1,4 +1,5 @@ -import { db } from "@chatbotx.io/database/client" +import { db, eq } from "@chatbotx.io/database/client" +import { integrationModel } from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" class IntegrationGoogleSheetService extends BaseService { @@ -17,6 +18,14 @@ class IntegrationGoogleSheetService extends BaseService { } return integration } + + async disconnect(integrationId: string): Promise { + await db.transaction(async (tx) => { + await tx + .delete(integrationModel) + .where(eq(integrationModel.id, integrationId)) + }) + } } export const integrationGoogleSheetService = new IntegrationGoogleSheetService() diff --git a/packages/business/src/integration-instagram/service.ts b/packages/business/src/integration-instagram/service.ts index bc1c988ea0..1e510a136c 100644 --- a/packages/business/src/integration-instagram/service.ts +++ b/packages/business/src/integration-instagram/service.ts @@ -1,4 +1,11 @@ -import { and, db, eq, findOrFail, sql } from "@chatbotx.io/database/client" +import { + and, + type DatabaseClient, + db, + eq, + findOrFail, + sql, +} from "@chatbotx.io/database/client" import type { InstagramPersistentMenu, IntegrationUserInfo, @@ -270,6 +277,30 @@ class InstagramIntegrationService extends BaseService { return { integration, wasCreated } }) } + + listByWorkspaceId(workspaceId: string) { + return db.query.integrationInstagramModel.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "asc" }, + }) + } + + async updateProfileFields( + props: { id: string }, + data: Record, + tx: DatabaseClient, + ) { + await tx + .update(integrationInstagramModel) + .set(data) + .where(eq(integrationInstagramModel.id, props.id)) + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(integrationInstagramModel) + .where(eq(integrationInstagramModel.id, props.id)) + } } export const instagramIntegrationService = new InstagramIntegrationService() diff --git a/packages/business/src/integration-messenger/service.ts b/packages/business/src/integration-messenger/service.ts index 320512219e..960038b17d 100644 --- a/packages/business/src/integration-messenger/service.ts +++ b/packages/business/src/integration-messenger/service.ts @@ -1,17 +1,22 @@ import { and, + type DatabaseClient, db, eq, findOrFail, inArray, sql, } from "@chatbotx.io/database/client" -import type { - IntegrationUserInfo, - MessengerPersistentMenu, +import { + channelTypes, + type IntegrationUserInfo, + type MessengerPersistentMenu, } from "@chatbotx.io/database/partials" import { integrationMessengerRepository } from "@chatbotx.io/database/repositories" -import { integrationMessengerModel } from "@chatbotx.io/database/schema" +import { + integrationMessengerModel, + tagChannelModel, +} from "@chatbotx.io/database/schema" import type { IntegrationMessengerModel, MessengerMessageTemplateModel, @@ -58,6 +63,16 @@ class MessengerIntegrationService extends BaseService { }) } + /** No workspace scope — targets in a clone-across-workspaces flow may live in other workspaces. */ + findByIds(ids: string[]) { + if (ids.length === 0) { + return Promise.resolve([]) + } + return db.query.integrationMessengerModel.findMany({ + where: { id: { in: ids } }, + }) + } + findByPageId(props: { workspaceId: string; pageId: string }) { return db.query.integrationMessengerModel.findFirst({ where: { workspaceId: props.workspaceId, pageId: props.pageId }, @@ -274,6 +289,66 @@ class MessengerIntegrationService extends BaseService { }, }) } + + listByWorkspaceIdOrId( + where: Partial>, + ) { + return db.query.integrationMessengerModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + async updateTagSync(props: { + workspaceId: string + integrationId: string + enabled: boolean + }): Promise { + const updated = await db + .update(integrationMessengerModel) + .set({ syncTagEnabledAt: props.enabled ? new Date() : null }) + .where( + and( + eq(integrationMessengerModel.id, props.integrationId), + eq(integrationMessengerModel.workspaceId, props.workspaceId), + ), + ) + .returning({ + syncTagEnabledAt: integrationMessengerModel.syncTagEnabledAt, + }) + + return updated[0]?.syncTagEnabledAt ?? null + } + + async updateProfileFields( + props: { id: string }, + data: Record, + tx: DatabaseClient, + ) { + await tx + .update(integrationMessengerModel) + .set(data) + .where(eq(integrationMessengerModel.id, props.id)) + } + + /** + * Deletes the integration row and its polymorphic TagChannel entries within + * the caller's transaction. Coexist teardown, remote unsubscribe, and inbox + * disconnect stay orchestrated by the caller. + */ + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.messenger), + eq(tagChannelModel.integrationId, props.id), + ), + ) + await props.tx + .delete(integrationMessengerModel) + .where(eq(integrationMessengerModel.id, props.id)) + } } export const messengerIntegrationService = new MessengerIntegrationService() diff --git a/packages/business/src/integration-openai/service.ts b/packages/business/src/integration-openai/service.ts index 13c52aa027..60942d796e 100644 --- a/packages/business/src/integration-openai/service.ts +++ b/packages/business/src/integration-openai/service.ts @@ -1,12 +1,66 @@ import { db, eq } from "@chatbotx.io/database/client" -import { integrationModel } from "@chatbotx.io/database/schema" +import { + integrationModel, + integrationOpenaiModel, +} from "@chatbotx.io/database/schema" import { BaseService } from "../base.service" +import { + type ConnectAiProviderInput, + connectAiProviderIntegration, +} from "../integration-ai-provider/connect" + +export type UpdateOpenAIInput = { autoReply?: boolean } class IntegrationOpenAIService extends BaseService { findByWorkspaceId(workspaceId: string) { return db.query.integrationOpenaiModel.findFirst({ where: { workspaceId } }) } + findByWorkspaceIdAndId(props: { workspaceId: string; id: string }) { + return db.query.integrationOpenaiModel.findFirst({ + where: { workspaceId: props.workspaceId, id: props.id }, + }) + } + + async connect(input: ConnectAiProviderInput) { + const existing = await this.findByWorkspaceId(input.workspaceId) + + await connectAiProviderIntegration({ + table: integrationOpenaiModel, + integrationType: "openai", + input, + existing, + }) + + await this.audit( + existing ? "update" : "connect", + existing + ? "updated the OpenAI integration configuration" + : "connected a new OpenAI integration", + ) + } + + async update( + props: { workspaceId: string; id: string }, + data: UpdateOpenAIInput, + ) { + const existing = await this.findByWorkspaceIdAndId(props) + if (!existing) { + throw new Error("Integration OpenAI not found") + } + + const result = await db + .update(integrationOpenaiModel) + .set(data) + .where(eq(integrationOpenaiModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) + + await this.audit("update", "updated the OpenAI integration configuration") + + return result + } + async disconnect(workspaceId: string) { const existing = await this.findByWorkspaceId(workspaceId) if (!existing) { diff --git a/packages/business/src/integration-smtp/service.ts b/packages/business/src/integration-smtp/service.ts index 4882c1c38e..3ddb69e295 100644 --- a/packages/business/src/integration-smtp/service.ts +++ b/packages/business/src/integration-smtp/service.ts @@ -1,6 +1,40 @@ -import { db } from "@chatbotx.io/database/client" +import { db, eq, findOrFail } from "@chatbotx.io/database/client" +import { channelTypes } from "@chatbotx.io/database/partials" +import { integrationSmtpModel } from "@chatbotx.io/database/schema" import type { IntegrationSmtpModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { isSameJsonValue } from "../audit/diff" import { BaseService } from "../base.service" +import { ChatbotXException } from "../errors" +import { connectChannelIntegration } from "../inbox/connect-channel" +import { inboxService } from "../inbox/service" +import { workspaceService } from "../workspace/service" + +/** + * Mirrors `@chatbotx.io/integration-smtp`'s `SmtpAuthValue` structurally — + * `packages/business` must not depend on an `integrations/*` package, so the + * caller (app layer) resolves `provider`'s default host/port via that + * package's `smtpHostMap` before calling `create`/`update`. + */ +export type SmtpAuthValue = { + authType: "custom" + provider: string + host: string + port: number + username: string + password: string +} + +export type CreateSmtpInput = { + provider: string + host: string + port: number + username: string + password: string + fromAddress: string +} + +export type UpdateSmtpInput = CreateSmtpInput class IntegrationSmtpService extends BaseService { find({ @@ -8,16 +42,165 @@ class IntegrationSmtpService extends BaseService { }: { where: Partial<{ workspaceId: string; id: string }> }): Promise { - // return withCache( - // `integrationSmtp:find:${btoa(JSON.stringify(where))}`, - // () => return db.query.integrationSmtpModel.findFirst({ where, }) - // { - // tags: ["integrationSmtp"], - // }, - // ) + } + + listByWorkspaceId(workspaceId: string) { + return db.query.integrationSmtpModel.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }) + } + + findByIdForWorkspace(props: { id: string; workspaceId: string }) { + return findOrFail({ + table: integrationSmtpModel, + where: props, + message: "SMTP integration not found", + }) + } + + /** + * Callers must: + * 1. verify the SMTP connection (via `verifySmtpConnection` in + * `apps/builder/src/features/integration-smtp/services/smtp.service.ts`) + * — needs `next-intl` to translate the failure message, which a service + * cannot call. + * 2. resolve `input.host`/`input.port` to the provider's default (via that + * same file's `smtpHostMap`) when `input.provider !== "other"` — the + * map lives in `@chatbotx.io/integration-smtp`, which `packages/business` + * must not depend on. + */ + async create( + workspaceId: string, + input: CreateSmtpInput, + ): Promise<{ id: string }> { + const { host, port } = input + + const workspace = await workspaceService.find({ + where: { id: workspaceId }, + }) + if (!workspace) { + throw new ChatbotXException("Workspace not found") + } + + const { inbox, wasCreated } = await db.transaction(async (tx) => { + const smtpId = createId() + const name = input.username + + return await connectChannelIntegration({ + tx, + ownerId: workspace.ownerId, + inboxData: { + id: smtpId, + workspaceId, + channel: channelTypes.enum.smtp, + name, + sourceId: smtpId, + }, + insertIntegration: async (inboxId) => { + await tx.insert(integrationSmtpModel).values({ + id: smtpId, + name, + workspaceId, + inboxId, + fromAddress: input.fromAddress, + auth: { + authType: "custom" as const, + provider: input.provider, + username: input.username, + password: input.password, + host, + port, + }, + }) + }, + }) + }) + + if (wasCreated) { + await this.audit("connect", `connected a new SMTP channel (#${inbox.id})`) + } + + return inbox + } + + async update( + workspaceId: string, + id: string, + input: UpdateSmtpInput, + ): Promise { + const integration = await this.findByIdForWorkspace({ id, workspaceId }) + const currentAuth = integration.auth as SmtpAuthValue + const provider = input.provider ?? currentAuth.provider + const host = input.host || currentAuth.host + const port = input.port || currentAuth.port + + const updatedAuth: SmtpAuthValue = { + authType: "custom", + provider, + host, + port, + username: input.username ?? currentAuth.username, + password: input.password ?? currentAuth.password, + } + + const name = input.username ?? integration.name + const fromAddress = input.fromAddress ?? integration.fromAddress + + const updated = await db + .update(integrationSmtpModel) + .set({ auth: updatedAuth, name, fromAddress }) + .where(eq(integrationSmtpModel.id, integration.id)) + .returning() + .then((result) => result[0]) + + if (!updated) { + throw new Error("Failed to update SMTP integration") + } + + const hasChanged = !isSameJsonValue( + { auth: updatedAuth, name, fromAddress }, + { + auth: currentAuth, + name: integration.name, + fromAddress: integration.fromAddress, + }, + ) + + if (hasChanged) { + await this.audit("update", "updated the SMTP channel configuration") + } + + return updated + } + + async delete(workspaceId: string, id: string): Promise { + const [integration, workspace] = await Promise.all([ + this.findByIdForWorkspace({ id, workspaceId }), + workspaceService.findById({ id: workspaceId }), + ]) + + await db.transaction(async (tx) => { + await tx + .delete(integrationSmtpModel) + .where(eq(integrationSmtpModel.id, integration.id)) + + await inboxService.disconnect({ + inboxId: integration.inboxId, + ownerId: workspace.ownerId, + workspaceId, + reason: "manual", + tx, + }) + }) + + await this.audit( + "disconnect", + `disconnected the SMTP channel (#${integration.id})`, + ) } } export const integrationSmtpService = new IntegrationSmtpService() diff --git a/packages/business/src/integration-telegram/service.ts b/packages/business/src/integration-telegram/service.ts index 263eb604ad..f473d0f47f 100644 --- a/packages/business/src/integration-telegram/service.ts +++ b/packages/business/src/integration-telegram/service.ts @@ -1,6 +1,24 @@ -import { findOrFail } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + eq, + findOrFail, +} from "@chatbotx.io/database/client" import { integrationTelegramModel } from "@chatbotx.io/database/schema" +import type { IntegrationTelegramModel } from "@chatbotx.io/database/types" +import type { SecretTextAuthValue } from "@chatbotx.io/sdk" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectTelegramInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + botId: string + botUsername: string + botToken: string +} class TelegramIntegrationService extends BaseService { findByInboxIdForWorkspace(props: { inboxId: string; workspaceId: string }) { @@ -9,6 +27,78 @@ class TelegramIntegrationService extends BaseService { where: { inboxId: props.inboxId, workspaceId: props.workspaceId }, }) } + + findByWorkspaceIdAndId(props: { workspaceId: string; id: string }) { + return findOrFail({ + table: integrationTelegramModel, + where: { workspaceId: props.workspaceId, id: props.id }, + message: "Integration Telegram not found", + }) + } + + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationTelegramModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationTelegramModel.findFirst({ + where: { workspaceId }, + }) + } + + /** No auth check — for use by the webhook handler only. */ + findByBotId(botId: string) { + return db.query.integrationTelegramModel.findFirst({ + where: { botId }, + }) + } + + async connect(input: ConnectTelegramInput) { + const auth: SecretTextAuthValue = { + authType: "secretText", + secretText: input.botToken, + } + const integrationId = createId() + + const { wasCreated } = await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + id: createId(), + workspaceId: input.workspaceId, + name: input.botUsername, + channel: "telegram", + sourceId: input.botId, + }, + insertIntegration: async (inboxId) => { + await input.tx.insert(integrationTelegramModel).values({ + id: integrationId, + inboxId, + workspaceId: input.workspaceId, + botId: input.botId, + name: input.botUsername, + auth, + }) + }, + }) + + return { integrationId, wasCreated } + } + + async disconnect(props: { + workspaceId: string + id: string + tx: DatabaseClient + }) { + await props.tx + .delete(integrationTelegramModel) + .where(eq(integrationTelegramModel.id, props.id)) + } } export const telegramIntegrationService = new TelegramIntegrationService() diff --git a/packages/business/src/integration-tiktok/service.ts b/packages/business/src/integration-tiktok/service.ts index 50a74b3ab1..0425345440 100644 --- a/packages/business/src/integration-tiktok/service.ts +++ b/packages/business/src/integration-tiktok/service.ts @@ -1,6 +1,25 @@ -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" import { integrationTiktokModel } from "@chatbotx.io/database/schema" +import type { IntegrationTiktokModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectTiktokInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + openId: string + username: string + displayName: string + auth: Record +} class TiktokIntegrationService extends BaseService { findById(props: { id: string; workspaceId: string }) { @@ -11,6 +30,67 @@ class TiktokIntegrationService extends BaseService { }) } + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationTiktokModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationTiktokModel.findFirst({ where: { workspaceId } }) + } + + findByOpenId(openId: string) { + return db.query.integrationTiktokModel.findFirst({ where: { openId } }) + } + + async connect(input: ConnectTiktokInput) { + const integrationId = createId() + + return await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + workspaceId: input.workspaceId, + name: input.displayName, + channel: "tiktok", + sourceId: input.username, + }, + insertIntegration: async (inboxId) => { + const [integration] = await input.tx + .insert(integrationTiktokModel) + .values({ + id: integrationId, + inboxId, + workspaceId: input.workspaceId, + openId: input.openId, + name: input.displayName, + auth: input.auth, + }) + .onConflictDoUpdate({ + target: [integrationTiktokModel.openId], + set: { + auth: input.auth, + name: input.displayName, + tokenRefreshError: null, + }, + }) + .returning({ id: integrationTiktokModel.id }) + + return integration + }, + }) + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + await props.tx + .delete(integrationTiktokModel) + .where(eq(integrationTiktokModel.id, props.id)) + } + findAll() { return db .select({ diff --git a/packages/business/src/integration-webchat/service.ts b/packages/business/src/integration-webchat/service.ts index 3291f26272..836caae938 100644 --- a/packages/business/src/integration-webchat/service.ts +++ b/packages/business/src/integration-webchat/service.ts @@ -23,6 +23,8 @@ export type CreateWebchatRequest = { welcomeFlowId?: string | null } +export type UpdateWebchatRequest = Partial + class IntegrationWebchatService extends BaseService { /** * Provisions a new Inbox + IntegrationWebchat row together, mirroring @@ -86,6 +88,56 @@ class IntegrationWebchatService extends BaseService { return created } + findByWorkspaceIdAndId(where: { id: string; workspaceId: string }) { + return findOrFail({ + table: integrationWebchatModel, + where, + message: "Integration webchat not found", + }) + } + + listByWorkspaceId(props: { + workspaceId: string + pagination?: { limit: number; offset: number } | null + }) { + const where = { workspaceId: props.workspaceId } + return Promise.all([ + db.query.integrationWebchatModel.findMany({ + where, + orderBy: { createdAt: "desc" }, + ...props.pagination, + }), + props.pagination?.limit + ? db.$count( + integrationWebchatModel, + eq(integrationWebchatModel.workspaceId, props.workspaceId), + ) + : Promise.resolve(1), + ]) + } + + async update( + where: { workspaceId: string; id: string }, + data: UpdateWebchatRequest & { + welcomeFlowId?: string | null + authorizedDomains?: string[] + }, + ): Promise { + const existing = await this.findByWorkspaceIdAndId(where) + + await db.transaction(async (tx) => { + await tx + .update(integrationWebchatModel) + .set({ + ...data, + conversationStarters: data.conversationStarters as never, + persistentMenus: data.persistentMenus as never, + workspaceId: where.workspaceId, + }) + .where(eq(integrationWebchatModel.id, existing.id)) + }) + } + async delete(input: { workspaceId: string; id: string }): Promise { const [integrationWebchat, workspace] = await Promise.all([ findOrFail({ diff --git a/packages/business/src/integration-whatsapp/service.ts b/packages/business/src/integration-whatsapp/service.ts index 7736e3cea2..4ca1997411 100644 --- a/packages/business/src/integration-whatsapp/service.ts +++ b/packages/business/src/integration-whatsapp/service.ts @@ -1,10 +1,23 @@ -import { type DatabaseClient, db } from "@chatbotx.io/database/client" +import { + and, + type DatabaseClient, + db, + eq, + inArray, +} from "@chatbotx.io/database/client" import type { WhatsappRegistrationStatus } from "@chatbotx.io/database/partials" import { integrationWhatsappRepository, + LIVE_RUN_STATUSES, + metaCapiEventRepository, whatsappSignupSessionRepository, } from "@chatbotx.io/database/repositories" -import type { IntegrationWhatsappRegistrationError } from "@chatbotx.io/database/schema" +import { + coexistSyncRunModel, + type IntegrationWhatsappRegistrationError, + integrationWhatsappModel, + whatsappCoexistStagingModel, +} from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, WhatsappMessageTemplateModel, @@ -14,6 +27,7 @@ import { encryptedDataSchema, encryptUtils } from "@chatbotx.io/encryption" import type { ChannelError } from "@chatbotx.io/sdk" import { z } from "zod" import { BaseService } from "../base.service" +import { inboxService } from "../inbox/service" import { logger } from "../logger" import { createDatasetWithFallback } from "../meta-conversions/dataset-fallback" import { platformCredentialService } from "../platform-credential/service" @@ -355,6 +369,17 @@ class IntegrationWhatsappService extends BaseService { return integrationWhatsappRepository.markTokenRefreshError(id, error) } + /** + * No workspace scope — for the inbound webhook-verification handler, which + * has not yet resolved a workspace when it stamps `webhookVerifiedAt`. + */ + markWebhookVerified( + id: string, + auth: Record, + ): Promise { + return integrationWhatsappRepository.updateAuthUnscoped(id, auth) + } + async refreshCapiScopeCache( input: RefreshCapiScopeCacheInput, ): Promise { @@ -632,6 +657,73 @@ class IntegrationWhatsappService extends BaseService { }, }) } + + /** + * Everything one disconnect must abandon or delete, in a single + * transaction. Sync history (importedCount / lastSyncedAt / …) is + * deliberately preserved for audit and so a reconnect can resume from the + * prior watermark; only ACTIVE runs are abandoned so the scheduler stops + * trying to drive them forward against a now-missing integration. + * `LIVE_RUN_STATUSES` includes `waiting`: a WhatsApp coexist run parked for + * more Meta history must be abandoned here too, otherwise the scheduler + * cannot revive it (its staging rows are deleted below) and it lingers + * until the 24h history-window timeout closes it. + */ + async disconnect(props: { + integrationWhatsapp: IntegrationWhatsappModel + ownerId: string + workspaceId: string + tx: DatabaseClient + }): Promise { + const { integrationWhatsapp, ownerId, workspaceId, tx } = props + + await tx + .update(coexistSyncRunModel) + .set({ + status: "failed", + finishedAt: new Date(), + currentError: "Integration disconnected", + }) + .where( + and( + eq(coexistSyncRunModel.integrationId, integrationWhatsapp.id), + inArray(coexistSyncRunModel.status, LIVE_RUN_STATUSES), + ), + ) + + await tx + .delete(whatsappCoexistStagingModel) + .where( + eq( + whatsappCoexistStagingModel.phoneNumberId, + integrationWhatsapp.phoneNumberId, + ), + ) + + // Polymorphic FK cleanup — no DB-level cascade for + // MetaCapiEvent.integrationId; stale rows would keep occupying the + // (workspaceId, channel, sourceKey) dedup slot after a reconnect. + await metaCapiEventRepository.deleteByIntegration( + { + workspaceId, + channel: "whatsapp", + integrationId: integrationWhatsapp.id, + }, + tx, + ) + + await tx + .delete(integrationWhatsappModel) + .where(eq(integrationWhatsappModel.id, integrationWhatsapp.id)) + + await inboxService.disconnect({ + inboxId: integrationWhatsapp.inboxId, + ownerId, + workspaceId, + reason: "manual", + tx, + }) + } } export const integrationWhatsappService = new IntegrationWhatsappService() diff --git a/packages/business/src/integration-zalo/service.ts b/packages/business/src/integration-zalo/service.ts index cb45011e2a..2ade897868 100644 --- a/packages/business/src/integration-zalo/service.ts +++ b/packages/business/src/integration-zalo/service.ts @@ -1,8 +1,113 @@ -import { db, eq, findOrFail, inArray } from "@chatbotx.io/database/client" -import { integrationZaloModel } from "@chatbotx.io/database/schema" +import { + and, + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" +import { channelTypes } from "@chatbotx.io/database/partials" +import { + integrationZaloModel, + tagChannelModel, +} from "@chatbotx.io/database/schema" +import type { IntegrationZaloModel } from "@chatbotx.io/database/types" import { BaseService } from "../base.service" +import { connectChannelIntegration } from "../inbox/connect-channel" + +export type ConnectZaloInput = { + tx: DatabaseClient + ownerId: string + workspaceId: string + oaId: string + oaName: string + auth: Record +} class ZaloIntegrationService extends BaseService { + listByWorkspaceId( + where: Partial>, + ) { + return db.query.integrationZaloModel.findMany({ + where, + orderBy: { createdAt: "asc" }, + }) + } + + findByWorkspaceId(workspaceId: string) { + return db.query.integrationZaloModel.findFirst({ where: { workspaceId } }) + } + + /** + * Returns `wasCreated: false` (no insert performed) when the OA is already + * connected elsewhere — the caller (app layer) decides whether to redirect; + * `redirect()` must not be called from inside a service. + */ + async connect( + input: ConnectZaloInput, + ): Promise<{ integrationId: string | undefined; wasCreated: boolean }> { + let connectedIntegrationId: string | undefined + + const { wasCreated } = await connectChannelIntegration({ + tx: input.tx, + ownerId: input.ownerId, + inboxData: { + workspaceId: input.workspaceId, + name: input.oaName, + channel: "zalo", + sourceId: input.oaId, + }, + insertIntegration: async (inboxId, insertWasCreated) => { + if (!insertWasCreated) { + return + } + const [row] = await input.tx + .insert(integrationZaloModel) + .values({ + inboxId, + workspaceId: input.workspaceId, + oaId: input.oaId, + auth: input.auth, + name: input.oaName, + }) + .returning({ id: integrationZaloModel.id }) + connectedIntegrationId = row?.id + }, + }) + + return { integrationId: connectedIntegrationId, wasCreated } + } + + async disconnect(props: { id: string; tx: DatabaseClient }) { + // Polymorphic FK cleanup — no DB-level cascade for TagChannel.integrationId + await props.tx + .delete(tagChannelModel) + .where( + and( + eq(tagChannelModel.channelType, channelTypes.enum.zalo), + eq(tagChannelModel.integrationId, props.id), + ), + ) + await props.tx + .delete(integrationZaloModel) + .where(eq(integrationZaloModel.id, props.id)) + } + + async updateTagSync(props: { + workspaceId: string + integrationId: string + enabled: boolean + }) { + await db + .update(integrationZaloModel) + .set({ syncTagEnabledAt: props.enabled ? new Date() : null }) + .where( + and( + eq(integrationZaloModel.id, props.integrationId), + eq(integrationZaloModel.workspaceId, props.workspaceId), + ), + ) + } async findAll(): Promise< Array<{ id: string; workspaceId: string; auth: Record }> > { diff --git a/packages/business/src/integration/service.ts b/packages/business/src/integration/service.ts index 5e2c0524d6..5b7a486b8d 100644 --- a/packages/business/src/integration/service.ts +++ b/packages/business/src/integration/service.ts @@ -36,6 +36,31 @@ export type TokenRefreshErrorIntegration = { } 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() diff --git a/packages/business/src/messenger-message-template/index.ts b/packages/business/src/messenger-message-template/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/messenger-message-template/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/messenger-message-template/service.ts b/packages/business/src/messenger-message-template/service.ts new file mode 100644 index 0000000000..b74a00290e --- /dev/null +++ b/packages/business/src/messenger-message-template/service.ts @@ -0,0 +1,258 @@ +import { + and, + type DatabaseClient, + db, + eq, + ilike, + inArray, +} from "@chatbotx.io/database/client" +import type { MessengerTemplateStatus } from "@chatbotx.io/database/partials" +import { messengerMessageTemplateModel } from "@chatbotx.io/database/schema" +import { + getPaginationWithDefaults, + likeContains, +} from "@chatbotx.io/database/utils" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +type MessengerMessageTemplateListWhere = { + workspaceId: string + inboxId?: string + integrationMessengerId?: string + status?: MessengerTemplateStatus + name?: string +} + +/** Meta's message-template shape, as returned by `listMessageTemplates`. */ +export type MetaMessengerTemplate = { + id: string + name: string + language: string + category: string + status: string + parameter_format?: string + components: unknown +} + +class MessengerMessageTemplateService extends BaseService { + private async resolveIntegrationMessengerId({ + tx, + where, + }: { + tx: DatabaseClient + where: MessengerMessageTemplateListWhere + }) { + let resolvedIntegrationMessengerId = where.integrationMessengerId + + if (!resolvedIntegrationMessengerId && where.inboxId) { + const integration = await tx.query.integrationMessengerModel.findFirst({ + where: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + columns: { id: true }, + }) + resolvedIntegrationMessengerId = integration?.id + } + + return resolvedIntegrationMessengerId + } + + async list(props: { + tx?: DatabaseClient + where: MessengerMessageTemplateListWhere + }) { + const { tx = db, where } = props + + // Resolve integrationMessengerId from inboxId when only inboxId is given. + // Relying on nested relational filtering for inboxId is fragile and ORM- + // version-sensitive because messengerMessageTemplateModel has no direct + // inboxId column. + const resolvedIntegrationMessengerId = + await this.resolveIntegrationMessengerId({ tx, where }) + + return tx.query.messengerMessageTemplateModel.findMany({ + where: { + status: where.status, + integrationMessengerId: resolvedIntegrationMessengerId, + integrationMessenger: { + workspaceId: where.workspaceId, + }, + }, + with: { + integrationMessenger: true, + }, + orderBy: { id: "desc" }, + }) + } + + async listPaginated(props: { + tx?: DatabaseClient + where: MessengerMessageTemplateListWhere + page?: number + perPage?: number + }) { + const { tx = db, where } = props + const resolvedIntegrationMessengerId = + await this.resolveIntegrationMessengerId({ tx, where }) + const queryWhere = { + name: where.name ? { ilike: likeContains(where.name) } : undefined, + status: where.status, + integrationMessengerId: resolvedIntegrationMessengerId, + integrationMessenger: { + workspaceId: where.workspaceId, + }, + } + const pagination = getPaginationWithDefaults({ + page: props.page, + perPage: props.perPage, + }) + + const [data, total] = await Promise.all([ + tx.query.messengerMessageTemplateModel.findMany({ + where: queryWhere, + with: { + integrationMessenger: true, + }, + orderBy: { id: "desc" }, + limit: pagination.limit, + offset: pagination.offset, + }), + tx.$count( + messengerMessageTemplateModel, + and( + where.name + ? ilike( + messengerMessageTemplateModel.name, + likeContains(where.name), + ) + : undefined, + where.status + ? eq(messengerMessageTemplateModel.status, where.status) + : undefined, + resolvedIntegrationMessengerId + ? eq( + messengerMessageTemplateModel.integrationMessengerId, + resolvedIntegrationMessengerId, + ) + : undefined, + ), + ), + ]) + + return { + data, + pageCount: Math.max(1, Math.ceil(total / pagination.limit)), + } + } + + findByIdForIntegration(props: { + id: string + integrationMessengerId: string + workspaceId: string + }) { + return db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationMessengerId: props.integrationMessengerId, + integrationMessenger: { workspaceId: props.workspaceId }, + }, + }) + } + + async delete(props: { + id: string + integrationMessengerId: string + }): Promise { + await db + .delete(messengerMessageTemplateModel) + .where( + and( + eq(messengerMessageTemplateModel.id, props.id), + eq( + messengerMessageTemplateModel.integrationMessengerId, + props.integrationMessengerId, + ), + ), + ) + } + + /** + * Merges Meta's current template list into the local table for one + * integration: an exact-match query (`templateId`/`templateName`/ + * `templateLanguage` all set) is a partial sync — only those templates are + * upserted, existing rows outside the filter are left alone. A full sync + * (no filter) also deletes local rows Meta no longer reports. + */ + async syncFromMeta(props: { + integrationMessengerId: string + templates: MetaMessengerTemplate[] + isPartialSync: boolean + }): Promise { + await db.transaction(async (tx) => { + if (!props.isPartialSync) { + const existingTemplates = await tx + .select({ + id: messengerMessageTemplateModel.id, + sourceId: messengerMessageTemplateModel.sourceId, + }) + .from(messengerMessageTemplateModel) + .where( + eq( + messengerMessageTemplateModel.integrationMessengerId, + props.integrationMessengerId, + ), + ) + + const incomingSourceIds = new Set(props.templates.map((t) => t.id)) + const templatesToDelete = existingTemplates.filter( + (t) => !incomingSourceIds.has(t.sourceId), + ) + + if (templatesToDelete.length > 0) { + await tx.delete(messengerMessageTemplateModel).where( + inArray( + messengerMessageTemplateModel.id, + templatesToDelete.map((t) => t.id), + ), + ) + } + } + + for (const template of props.templates) { + await tx + .insert(messengerMessageTemplateModel) + .values([ + { + id: createId(), + name: template.name, + integrationMessengerId: props.integrationMessengerId, + language: template.language, + category: template.category, + status: template.status, + parameterFormat: template.parameter_format ?? "POSITIONAL", + sourceId: template.id, + components: template.components, + }, + ]) + .onConflictDoUpdate({ + target: [ + messengerMessageTemplateModel.integrationMessengerId, + messengerMessageTemplateModel.sourceId, + ], + set: { + name: template.name, + language: template.language, + category: template.category, + status: template.status, + parameterFormat: template.parameter_format ?? "POSITIONAL", + components: template.components, + }, + }) + } + }) + } +} + +export const messengerMessageTemplateService = + new MessengerMessageTemplateService() diff --git a/packages/business/src/whatsapp-flow/index.ts b/packages/business/src/whatsapp-flow/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/whatsapp-flow/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/whatsapp-flow/service.ts b/packages/business/src/whatsapp-flow/service.ts new file mode 100644 index 0000000000..68ed372ea9 --- /dev/null +++ b/packages/business/src/whatsapp-flow/service.ts @@ -0,0 +1,122 @@ +import { + type DatabaseClient, + db, + eq, + findOrFail, + inArray, +} from "@chatbotx.io/database/client" +import { whatsappFlowModel } from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +/** WhatsApp's flow shape, as returned by `listFlows`. */ +export type MetaWhatsappFlow = { + id: string + name: string + status: string + categories: unknown + validation_errors: unknown +} + +class WhatsappFlowService extends BaseService { + list(props: { + tx?: DatabaseClient + where: { + workspaceId: string + inboxId?: string + integrationWhatsappId?: string + } + }) { + const { tx = db, where } = props + + const queryWhere = { + integrationWhatsappId: where.integrationWhatsappId, + integrationWhatsapp: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + } + + return tx.query.whatsappFlowModel.findMany({ + where: queryWhere, + with: { + integrationWhatsapp: true, + }, + orderBy: { createdAt: "asc" }, + }) + } + + findByIdUnscoped(id: string) { + return findOrFail({ + table: whatsappFlowModel, + where: { id }, + message: "Whatsapp flow not found", + }) + } + + async syncFromMeta(props: { + integrationWhatsappId: string + flows: MetaWhatsappFlow[] + }): Promise { + await db.transaction(async (tx) => { + const existingFlows = await tx + .select({ + id: whatsappFlowModel.id, + sourceId: whatsappFlowModel.sourceId, + }) + .from(whatsappFlowModel) + .where( + eq( + whatsappFlowModel.integrationWhatsappId, + props.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set(props.flows.map((f) => f.id)) + + const flowsToDelete = existingFlows.filter( + (f) => !incomingSourceIds.has(f.sourceId), + ) + + if (flowsToDelete.length > 0) { + await tx.delete(whatsappFlowModel).where( + inArray( + whatsappFlowModel.id, + flowsToDelete.map((f) => f.id), + ), + ) + } + + for (const flow of props.flows) { + const existing = existingFlows.find((f) => f.sourceId === flow.id) + + if (existing) { + await tx + .update(whatsappFlowModel) + .set({ + name: flow.name, + status: flow.status, + categories: flow.categories, + validationErrors: flow.validation_errors, + }) + .where(eq(whatsappFlowModel.id, existing.id)) + } else { + await tx.insert(whatsappFlowModel).values([ + { + id: createId(), + name: flow.name, + integrationWhatsappId: props.integrationWhatsappId, + sourceId: flow.id, + status: flow.status, + categories: flow.categories, + validationErrors: flow.validation_errors, + completedCount: "0", + }, + ]) + } + } + }) + } +} + +export const whatsappFlowService = new WhatsappFlowService() diff --git a/packages/business/src/whatsapp-message-template/index.ts b/packages/business/src/whatsapp-message-template/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/whatsapp-message-template/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/whatsapp-message-template/service.ts b/packages/business/src/whatsapp-message-template/service.ts new file mode 100644 index 0000000000..4760cb274f --- /dev/null +++ b/packages/business/src/whatsapp-message-template/service.ts @@ -0,0 +1,121 @@ +import { + type DatabaseClient, + db, + eq, + inArray, +} from "@chatbotx.io/database/client" +import type { WhatsappTemplateStatus } from "@chatbotx.io/database/partials" +import { whatsappMessageTemplateModel } from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" + +/** WhatsApp's message-template shape, as returned by `listMessageTemplates`. */ +export type MetaWhatsappTemplate = { + id: string + name: string + language: string + category: string + status: string + components: unknown +} + +class WhatsappMessageTemplateService extends BaseService { + list(props: { + tx?: DatabaseClient + where: { + workspaceId: string + inboxId?: string + integrationWhatsappId?: string + status?: WhatsappTemplateStatus + } + }) { + const { tx = db, where } = props + + const queryWhere = { + integrationWhatsappId: where.integrationWhatsappId, + integrationWhatsapp: { + workspaceId: where.workspaceId, + inboxId: where.inboxId, + }, + } + + return tx.query.whatsappMessageTemplateModel.findMany({ + where: queryWhere, + with: { + integrationWhatsapp: true, + }, + orderBy: { createdAt: "asc" }, + }) + } + + /** Full sync only — WhatsApp's template sync has no partial-match mode. */ + async syncFromMeta(props: { + integrationWhatsappId: string + templates: MetaWhatsappTemplate[] + }): Promise { + await db.transaction(async (tx) => { + const existingTemplates = await tx + .select({ + id: whatsappMessageTemplateModel.id, + sourceId: whatsappMessageTemplateModel.sourceId, + }) + .from(whatsappMessageTemplateModel) + .where( + eq( + whatsappMessageTemplateModel.integrationWhatsappId, + props.integrationWhatsappId, + ), + ) + + const incomingSourceIds = new Set(props.templates.map((t) => t.id)) + + const templatesToDelete = existingTemplates.filter( + (t) => !incomingSourceIds.has(t.sourceId), + ) + + if (templatesToDelete.length > 0) { + await tx.delete(whatsappMessageTemplateModel).where( + inArray( + whatsappMessageTemplateModel.id, + templatesToDelete.map((t) => t.id), + ), + ) + } + + for (const template of props.templates) { + const existing = existingTemplates.find( + (t) => t.sourceId === template.id, + ) + + if (existing) { + await tx + .update(whatsappMessageTemplateModel) + .set({ + name: template.name, + language: template.language, + category: template.category, + status: template.status, + components: template.components, + }) + .where(eq(whatsappMessageTemplateModel.id, existing.id)) + } else { + await tx.insert(whatsappMessageTemplateModel).values([ + { + id: createId(), + name: template.name, + integrationWhatsappId: props.integrationWhatsappId, + language: template.language, + category: template.category, + status: template.status, + sourceId: template.id, + components: template.components, + }, + ]) + } + } + }) + } +} + +export const whatsappMessageTemplateService = + new WhatsappMessageTemplateService() diff --git a/packages/database/src/repositories/integration-whatsapp/repository.ts b/packages/database/src/repositories/integration-whatsapp/repository.ts index 9fe242344c..12f17c0eb7 100644 --- a/packages/database/src/repositories/integration-whatsapp/repository.ts +++ b/packages/database/src/repositories/integration-whatsapp/repository.ts @@ -195,6 +195,21 @@ class IntegrationWhatsappRepository { .where(eq(integrationWhatsappModel.id, id)) } + /** + * No workspace scope — called from the inbound webhook-verification handler + * before a workspace context is resolved. + */ + async updateAuthUnscoped( + id: string, + auth: Record, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(integrationWhatsappModel) + .set({ auth }) + .where(eq(integrationWhatsappModel.id, id)) + } + /** * Resolves the WhatsApp integration that owns a given `Inbox.id`. Ads * conversion trigger hook points (tag applied, keyword matched, contact From 3526972e8c6ca76b5b7e591e2720a460348f5024 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 19:26:08 +0700 Subject: [PATCH 3/5] feat(api): widen the integrations public API scope with AI provider endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /v1/integrations/{id}, GET /v1/integrations/status/token-errors, and GET/PUT/DELETE /v1/integrations/ai/{provider} (claude/deepseek/gemini/openai) to the existing `integrations` workspace-token scope, building on the consolidated AI-provider services from the prior commit. - Splits apps/builder/src/features/integrations/api/public.ts into public/{crud,ai}.ts, matching the contacts/ecommerce public API convention. - The AI-provider response never includes the encrypted `auth` field — only a `hasApiKey` boolean — enforced by an explicit assertion in integrations-public-api.test.ts. - integrations-public-scope.test.ts verifies every submodule registers under the `integrations` scope. - Regenerates the committed public-spec-operations snapshot for the 6 new operations. Co-Authored-By: Claude Sonnet 5 --- .../public-spec-operations.test.ts.snap | 25 ++ .../__tests__/integrations-public-api.test.ts | 317 ++++++++++++++++++ .../integrations-public-scope.test.ts | 62 ++++ .../src/features/integrations/api/public.ts | 31 +- .../features/integrations/api/public/ai.ts | 123 +++++++ .../features/integrations/api/public/crud.ts | 77 +++++ .../integrations/schema/ai-provider.ts | 32 ++ .../features/integrations/schema/public.ts | 25 ++ packages/business/src/integration/service.ts | 9 + 9 files changed, 674 insertions(+), 27 deletions(-) create mode 100644 apps/builder/__tests__/integrations-public-api.test.ts create mode 100644 apps/builder/__tests__/integrations-public-scope.test.ts create mode 100644 apps/builder/src/features/integrations/api/public/ai.ts create mode 100644 apps/builder/src/features/integrations/api/public/crud.ts create mode 100644 apps/builder/src/features/integrations/schema/ai-provider.ts create mode 100644 apps/builder/src/features/integrations/schema/public.ts diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index 3b0f81df97..c242e8b418 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -452,11 +452,36 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "inboxTeams.list", "path": "/v1/teams", }, + { + "method": "PUT", + "operationId": "integrations.connectAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, + { + "method": "DELETE", + "operationId": "integrations.disconnectAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, + { + "method": "GET", + "operationId": "integrations.get", + "path": "/v1/integrations/{id}", + }, + { + "method": "GET", + "operationId": "integrations.getAiProvider", + "path": "/v1/integrations/ai/{provider}", + }, { "method": "GET", "operationId": "integrations.list", "path": "/v1/integrations", }, + { + "method": "GET", + "operationId": "integrations.tokenErrors", + "path": "/v1/integrations/status/token-errors", + }, { "method": "GET", "operationId": "keywords.list", diff --git a/apps/builder/__tests__/integrations-public-api.test.ts b/apps/builder/__tests__/integrations-public-api.test.ts new file mode 100644 index 0000000000..47e2bba7bc --- /dev/null +++ b/apps/builder/__tests__/integrations-public-api.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +class MockChatbotXException extends Error { + code: string + constructor(message: string, code = "systemError") { + super(message) + this.code = code + } +} + +vi.mock("@chatbotx.io/business/errors", () => ({ + notFoundException: vi.fn( + (message: string) => new MockChatbotXException(message, "notFound"), + ), +})) + +const integrationService = { + listByWorkspaceId: vi.fn(), + findByIdForWorkspace: vi.fn(), + findTokenRefreshErrorsByWorkspaceId: vi.fn(), +} + +const integrationClaudeService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationDeepSeekService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationGeminiService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} +const integrationOpenAIService = { + findByWorkspaceId: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + integrationService, + integrationClaudeService, + integrationDeepSeekService, + integrationGeminiService, + integrationOpenAIService, +})) + +await import("@/features/integrations/api/public/crud") +await import("@/features/integrations/api/public/ai") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("GET /v1/integrations", () => { + const procedure = findProcedure("GET", "/v1/integrations") + + test("delegates to integrationService.listByWorkspaceId", async () => { + integrationService.listByWorkspaceId.mockResolvedValueOnce([ + { id: "integration-1" }, + ]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(result).toEqual({ data: [{ id: "integration-1" }], pageCount: 1 }) + expect(integrationService.listByWorkspaceId).toHaveBeenCalledWith( + "workspace-1", + ) + }) +}) + +describe("GET /v1/integrations/{id}", () => { + const procedure = findProcedure("GET", "/v1/integrations/{id}") + + test("delegates to integrationService.findByIdForWorkspace", async () => { + integrationService.findByIdForWorkspace.mockResolvedValueOnce({ + id: "integration-1", + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "integration-1" }, + }) + + expect(result).toEqual({ id: "integration-1" }) + expect(integrationService.findByIdForWorkspace).toHaveBeenCalledWith({ + id: "integration-1", + workspaceId: "workspace-1", + }) + }) + + test("throws notFound when the integration does not exist", async () => { + integrationService.findByIdForWorkspace.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing" }, + }), + ).rejects.toThrow("Integration not found") + }) +}) + +describe("GET /v1/integrations/status/token-errors", () => { + const procedure = findProcedure("GET", "/v1/integrations/status/token-errors") + + test("delegates to integrationService.findTokenRefreshErrorsByWorkspaceId", async () => { + integrationService.findTokenRefreshErrorsByWorkspaceId.mockResolvedValueOnce( + [{ id: "zalo-1", channel: "zalo", name: "Shop", error: "expired" }], + ) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + }) + + expect(result).toEqual({ + data: [{ id: "zalo-1", channel: "zalo", name: "Shop", error: "expired" }], + }) + }) +}) + +describe("GET /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("GET", "/v1/integrations/ai/{provider}") + + test("never returns the secret auth field", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce({ + id: "claude-1", + model: "claude-opus", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: true, + auth: { authType: "secretText", secretText: "sk-real-secret-value" }, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }) + + expect(result).toEqual({ + id: "claude-1", + model: "claude-opus", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: true, + hasApiKey: true, + }) + expect(JSON.stringify(result)).not.toContain("sk-real-secret-value") + expect(result).not.toHaveProperty("auth") + }) + + test("hasApiKey is false when no auth is stored", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce({ + id: "claude-1", + model: "claude-opus", + temperature: null, + maxOutputTokens: 1024, + autoReply: false, + auth: null, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }) + + expect(result).toMatchObject({ hasApiKey: false }) + }) + + test("throws notFound when the provider is not connected", async () => { + integrationClaudeService.findByWorkspaceId.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "claude" }, + }), + ).rejects.toThrow("claude integration not found") + }) + + test("dispatches to the correct provider service", async () => { + integrationOpenAIService.findByWorkspaceId.mockResolvedValueOnce({ + id: "openai-1", + model: "gpt-5", + temperature: 1, + maxOutputTokens: 2048, + autoReply: true, + auth: { authType: "secretText", secretText: "sk-openai" }, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "openai" }, + }) + + expect(integrationOpenAIService.findByWorkspaceId).toHaveBeenCalledWith( + "workspace-1", + ) + expect(integrationClaudeService.findByWorkspaceId).not.toHaveBeenCalled() + }) +}) + +describe("PUT /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("PUT", "/v1/integrations/ai/{provider}") + + test("connects then returns the resource without the secret", async () => { + integrationGeminiService.connect.mockResolvedValueOnce(undefined) + integrationGeminiService.findByWorkspaceId.mockResolvedValueOnce({ + id: "gemini-1", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: false, + auth: { authType: "secretText", secretText: "sk-gemini-secret" }, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }) + + expect(integrationGeminiService.connect).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }) + expect(JSON.stringify(result)).not.toContain("sk-gemini-secret") + }) +}) + +describe("DELETE /v1/integrations/ai/{provider}", () => { + const procedure = findProcedure("DELETE", "/v1/integrations/ai/{provider}") + + test("delegates to the provider's disconnect", async () => { + integrationDeepSeekService.disconnect.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "deepseek" }, + }) + + expect(integrationDeepSeekService.disconnect).toHaveBeenCalledWith( + "workspace-1", + ) + }) +}) diff --git a/apps/builder/__tests__/integrations-public-scope.test.ts b/apps/builder/__tests__/integrations-public-scope.test.ts new file mode 100644 index 0000000000..dc593f9e63 --- /dev/null +++ b/apps/builder/__tests__/integrations-public-scope.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest" + +// Same rationale as contacts-public-scope.test.ts: importing the real +// integrations public router transitively pulls in +// `@chatbotx.io/database/client` (opens a real `pg.Pool`) and `@/orpc`'s +// `authorizedAPI` chain (boots the full better-auth stack via +// `@/middlewares/auth`). Neither is reachable from this test — it only +// inspects which scope each submodule registered its procedures under — so +// both are stubbed to keep the import side-effect-free. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), + workspaceAuthorizedMidddleware: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => { + const proxy: unknown = new Proxy(() => proxy, { get: () => proxy }) + return { db: proxy } +}) + +const workspaceTokenAuthAPIForScope = vi.hoisted(() => + vi.fn((_scope: string) => { + const chain = { + route: vi.fn(() => chain), + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn(() => ({})), + } + return chain + }), +) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +// Each submodule calls `workspaceTokenAuthAPIForScope` exactly once at import +// time — `packages/utils`' Snowflake ID generator is a process-wide singleton +// that throws on re-init, so every submodule is imported exactly once here +// (no `vi.resetModules()` between them) and the full accumulated call list is +// asserted at the end, per submodule slice. +await import("@/features/integrations/api/public/crud") +const crudCallCount = workspaceTokenAuthAPIForScope.mock.calls.length + +await import("@/features/integrations/api/public/ai") +const aiCallCount = workspaceTokenAuthAPIForScope.mock.calls.length + +const allScopeCalls = workspaceTokenAuthAPIForScope.mock.calls.map( + (call) => call[0], +) + +describe("integrations public router scope wiring", () => { + test("crud.ts registers under the 'integrations' scope", () => { + expect(allScopeCalls.slice(0, crudCallCount)).toEqual(["integrations"]) + }) + + test("ai.ts registers under the 'integrations' scope", () => { + expect(allScopeCalls.slice(crudCallCount, aiCallCount)).toEqual([ + "integrations", + ]) + }) +}) diff --git a/apps/builder/src/features/integrations/api/public.ts b/apps/builder/src/features/integrations/api/public.ts index e5de7c2ddb..ef370a13d2 100644 --- a/apps/builder/src/features/integrations/api/public.ts +++ b/apps/builder/src/features/integrations/api/public.ts @@ -1,30 +1,7 @@ -import { integrationService } from "@chatbotx.io/business" -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" -import { - paginateInMemory, - publicListRequest, - publicListResponse, -} from "@/lib/public-api/list" -import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { publicIntegrationResource } from "../schema/resource" - -const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") +import { integrationsAiPublicRouter } from "./public/ai" +import { integrationsCrudPublicRouter } from "./public/crud" export const integrationsPublicRouter = { - list: workspaceTokenAuthAPI - .route({ - method: "GET", - path: "/v1/integrations", - summary: "List integrations", - tags: ["Integrations"], - }) - .input(publicListRequest) - .output(publicListResponse(publicIntegrationResource)) - .errors(possibleErrorsOnListingResource) - .handler(async ({ context, input }) => { - const data = await integrationService.listByWorkspaceId( - context.workspace.id, - ) - return paginateInMemory(data, input) - }), + ...integrationsCrudPublicRouter, + ...integrationsAiPublicRouter, } diff --git a/apps/builder/src/features/integrations/api/public/ai.ts b/apps/builder/src/features/integrations/api/public/ai.ts new file mode 100644 index 0000000000..6b318c4e38 --- /dev/null +++ b/apps/builder/src/features/integrations/api/public/ai.ts @@ -0,0 +1,123 @@ +import { + integrationClaudeService, + integrationDeepSeekService, + integrationGeminiService, + integrationOpenAIService, +} from "@chatbotx.io/business" +import { notFoundException } from "@chatbotx.io/business/errors" +import { + possibleErrorsOnFindingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + type AiProviderPathParam, + connectAiProviderRequest, + getAiProviderRequest, + publicAiProviderResource, +} from "../../schema/ai-provider" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") + +type AiProviderRow = { + id: string + model: string + temperature: number | null + maxOutputTokens: number + autoReply: boolean + auth: unknown +} + +const toResource = (row: AiProviderRow) => ({ + id: row.id, + model: row.model, + temperature: row.temperature, + maxOutputTokens: row.maxOutputTokens, + autoReply: row.autoReply, + hasApiKey: Boolean(row.auth), +}) + +const aiProviderServices = { + claude: integrationClaudeService, + deepseek: integrationDeepSeekService, + gemini: integrationGeminiService, + openai: integrationOpenAIService, +} satisfies Record< + AiProviderPathParam, + { + findByWorkspaceId: ( + workspaceId: string, + ) => Promise + connect: (input: { + workspaceId: string + apiKey: string + model: string + temperature: number + maxOutputTokens: number + }) => Promise + disconnect: (workspaceId: string) => Promise + } +> + +export const integrationsAiPublicRouter = { + getAiProvider: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/ai/{provider}", + summary: "Get an AI provider integration", + tags: ["Integrations"], + }) + .input(getAiProviderRequest) + .output(publicAiProviderResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + const row = await service.findByWorkspaceId(context.workspace.id) + if (!row) { + throw notFoundException(`${input.provider} integration not found`) + } + return toResource(row) + }), + + connectAiProvider: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/integrations/ai/{provider}", + summary: "Connect or update an AI provider integration", + description: + "Upserts the AI provider integration for the workspace — connects it if not already configured, otherwise replaces the stored configuration (including the API key).", + tags: ["Integrations"], + }) + .input(getAiProviderRequest.extend(connectAiProviderRequest.shape)) + .output(publicAiProviderResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + await service.connect({ + workspaceId: context.workspace.id, + apiKey: input.apiKey, + model: input.model, + temperature: input.temperature, + maxOutputTokens: input.maxOutputTokens, + }) + const row = await service.findByWorkspaceId(context.workspace.id) + if (!row) { + throw notFoundException(`${input.provider} integration not found`) + } + return toResource(row) + }), + + disconnectAiProvider: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/integrations/ai/{provider}", + summary: "Disconnect an AI provider integration", + tags: ["Integrations"], + }) + .input(getAiProviderRequest) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const service = aiProviderServices[input.provider] + await service.disconnect(context.workspace.id) + }), +} diff --git a/apps/builder/src/features/integrations/api/public/crud.ts b/apps/builder/src/features/integrations/api/public/crud.ts new file mode 100644 index 0000000000..390f49d634 --- /dev/null +++ b/apps/builder/src/features/integrations/api/public/crud.ts @@ -0,0 +1,77 @@ +import { integrationService } from "@chatbotx.io/business" +import { notFoundException } from "@chatbotx.io/business/errors" +import { + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, +} from "@/lib/orpc/orpc-error-helper" +import { + paginateInMemory, + publicListRequest, + publicListResponse, +} from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + getIntegrationRequest, + listTokenRefreshErrorsResponse, +} from "../../schema/public" +import { publicIntegrationResource } from "../../schema/resource" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") + +export const integrationsCrudPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations", + summary: "List integrations", + tags: ["Integrations"], + }) + .input(publicListRequest) + .output(publicListResponse(publicIntegrationResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const data = await integrationService.listByWorkspaceId( + context.workspace.id, + ) + return paginateInMemory(data, input) + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/{id}", + summary: "Get an integration", + tags: ["Integrations"], + }) + .input(getIntegrationRequest) + .output(publicIntegrationResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const integration = await integrationService.findByIdForWorkspace({ + id: input.id, + workspaceId: context.workspace.id, + }) + if (!integration) { + throw notFoundException("Integration not found") + } + return integration + }), + + tokenErrors: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/integrations/status/token-errors", + summary: "List channel integrations with a failed token refresh", + description: + "Channel integrations whose daily automatic token-refresh last failed — a signal the channel needs a manual reconnect before it silently stops sending or receiving messages.", + tags: ["Integrations"], + }) + .output(listTokenRefreshErrorsResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context }) => { + const data = await integrationService.findTokenRefreshErrorsByWorkspaceId( + context.workspace.id, + ) + return { data } + }), +} diff --git a/apps/builder/src/features/integrations/schema/ai-provider.ts b/apps/builder/src/features/integrations/schema/ai-provider.ts new file mode 100644 index 0000000000..611a232d5a --- /dev/null +++ b/apps/builder/src/features/integrations/schema/ai-provider.ts @@ -0,0 +1,32 @@ +import { z } from "zod" + +export const aiProviderPathParam = z.enum([ + "claude", + "deepseek", + "gemini", + "openai", +]) +export type AiProviderPathParam = z.infer + +export const getAiProviderRequest = z.object({ + provider: aiProviderPathParam, +}) + +// Never includes `auth` — the encrypted/secret credential. Only `hasApiKey` +// (a boolean) signals whether a key is configured; the raw secret must never +// reach a public API response. +export const publicAiProviderResource = z.object({ + id: z.string(), + model: z.string(), + temperature: z.number().nullable(), + maxOutputTokens: z.number(), + autoReply: z.boolean(), + hasApiKey: z.boolean(), +}) + +export const connectAiProviderRequest = z.object({ + apiKey: z.string().min(1), + model: z.string().min(1), + temperature: z.coerce.number().min(0).max(2), + maxOutputTokens: z.coerce.number().int().min(1).max(8192), +}) diff --git a/apps/builder/src/features/integrations/schema/public.ts b/apps/builder/src/features/integrations/schema/public.ts new file mode 100644 index 0000000000..92becba97f --- /dev/null +++ b/apps/builder/src/features/integrations/schema/public.ts @@ -0,0 +1,25 @@ +import { z } from "zod" + +export const getIntegrationRequest = z.object({ + id: z.string(), +}) + +export const tokenRefreshErrorChannel = z.enum([ + "zalo", + "tiktok", + "instagram", + "instagramFacebook", + "messenger", + "whatsapp", +]) + +export const tokenRefreshErrorResource = z.object({ + id: z.string(), + channel: tokenRefreshErrorChannel, + name: z.string(), + error: z.string(), +}) + +export const listTokenRefreshErrorsResponse = z.object({ + data: z.array(tokenRefreshErrorResource), +}) diff --git a/packages/business/src/integration/service.ts b/packages/business/src/integration/service.ts index 5b7a486b8d..6a0c5ead30 100644 --- a/packages/business/src/integration/service.ts +++ b/packages/business/src/integration/service.ts @@ -36,6 +36,15 @@ export type TokenRefreshErrorIntegration = { } class IntegrationService extends BaseService { + findByIdForWorkspace(props: { + id: string + workspaceId: string + }): Promise { + return db.query.integrationModel.findFirst({ + where: { id: props.id, workspaceId: props.workspaceId }, + }) + } + async listByWorkspaceIdAndTypes(props: { workspaceId: string integrationTypes: string[] From b72e4036614e6d9209e2a2519646ba5d4b8aff6e Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 07:40:31 +0700 Subject: [PATCH 4/5] refactor(business): tighten tenant scoping and rename ambiguous data-access methods Rename service methods that are not (or cannot be) workspace-scoped with an explicit `*Unscoped`/intent-revealing suffix (tag/contact-to-tag joins with no workspaceId column, coexist run claim/reclaim), and scope Messenger/ WhatsApp template lookups by workspaceId through the integration relation instead of leaving them keyed only by template id. Also verify the AI provider API key and invalidate the AI integration cache on connect/ disconnect in the public integrations API. --- .../__tests__/create-api.action.test.ts | 19 ++++ .../__tests__/integrations-public-api.test.ts | 92 +++++++++++++++++++ .../integration-ai/lib/verify-api-key.ts | 20 ++-- .../actions/create-api.action.ts | 5 + .../actions/update.action.ts | 2 +- .../actions/update.action.ts | 2 +- .../actions/connect.action.ts | 15 ++- .../actions/update.action.ts | 2 +- .../features/integration-gemini/lib/index.ts | 12 --- .../actions/disconnect.action.ts | 4 +- .../actions/connect.action.ts | 13 ++- .../features/integration-openai/lib/index.ts | 14 --- .../actions/connect.action.ts | 4 + .../actions/disconnect.action.ts | 1 - .../actions/create-webchat.action.ts | 5 + .../features/integrations/api/public/ai.ts | 25 ++++- .../__tests__/coexist-instagram-sync.test.ts | 2 +- .../__tests__/coexist-messenger-sync.test.ts | 4 +- .../coexist-whatsapp-flush-lifecycle.test.ts | 4 +- .../coexist-whatsapp-flush.payloads.test.ts | 2 +- .../coexist-whatsapp-flush.test-utils.ts | 2 +- .../__tests__/coexist-whatsapp-flush.test.ts | 2 +- apps/worker/__tests__/inbox-labels.test.ts | 84 ++++++++--------- apps/worker/__tests__/send-flow-step.test.ts | 2 +- .../src/chat/handlers/send-flow-step.ts | 2 +- .../handlers/coexist/instagram-sync.ts | 2 +- .../handlers/coexist/messenger-sync.ts | 2 +- .../coexist/whatsapp-flush-context.ts | 2 +- .../handlers/coexist/whatsapp-flush.ts | 4 +- .../integration/handlers/inbox_labels/sync.ts | 8 +- .../handlers/messenger-template-handler.ts | 11 ++- .../handlers/wa-template-handler.ts | 14 ++- .../__tests__/coexist.service.test.ts | 2 +- .../contact-service-flow-flags.test.ts | 5 +- packages/business/src/coexist/service.ts | 8 +- packages/business/src/contact/service.ts | 10 +- packages/business/src/conversation/service.ts | 10 +- packages/business/src/inbox/service.ts | 17 +++- .../src/integration-claude/service.ts | 12 ++- .../src/integration-deepseek/service.ts | 12 ++- .../src/integration-gemini/service.ts | 12 ++- .../src/integration-messenger/service.ts | 19 +--- .../src/integration-telegram/service.ts | 6 +- .../src/integration-whatsapp/service.ts | 16 ---- .../src/messenger-message-template/service.ts | 16 ++++ packages/business/src/tag/service.ts | 38 ++++++-- .../src/whatsapp-message-template/service.ts | 16 ++++ .../coexist-run-recovery-repository.test.ts | 14 +-- .../coexist-sync-run-repository.test.ts | 18 ++-- .../coexist-sync-run/repository.ts | 10 +- 50 files changed, 410 insertions(+), 213 deletions(-) delete mode 100644 apps/builder/src/features/integration-gemini/lib/index.ts delete mode 100644 apps/builder/src/features/integration-openai/lib/index.ts diff --git a/apps/builder/__tests__/create-api.action.test.ts b/apps/builder/__tests__/create-api.action.test.ts index 9e95f88ad6..e9c16bebb5 100644 --- a/apps/builder/__tests__/create-api.action.test.ts +++ b/apps/builder/__tests__/create-api.action.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ connect: vi.fn(), findWorkspaceOrFail: vi.fn(), createWorkspace: vi.fn(), + hasWorkspaceAccess: vi.fn(async () => true), generateApiChannelToken: vi.fn(async () => ({ token: "plain-token", tokenHash: "token-hash", @@ -24,6 +25,7 @@ vi.mock("@/lib/safe-action", () => { vi.mock("@chatbotx.io/business", () => ({ assertPublicUrl: mocks.assertPublicUrl, + hasWorkspaceAccess: mocks.hasWorkspaceAccess, integrationApiService: { connect: mocks.connect }, workspaceService: { findOrFail: mocks.findWorkspaceOrFail, @@ -52,6 +54,7 @@ type ActionHandler = (args: { describe("createApiAction", () => { beforeEach(() => { vi.clearAllMocks() + mocks.hasWorkspaceAccess.mockResolvedValue(true) mocks.findWorkspaceOrFail.mockResolvedValue({ id: "workspace-1", ownerId: "owner-1", @@ -92,4 +95,20 @@ describe("createApiAction", () => { token: "plain-token", }) }) + + test("rejects a workspaceId the caller is not a member of", async () => { + mocks.hasWorkspaceAccess.mockResolvedValue(false) + + await expect( + (createApiAction as unknown as ActionHandler)({ + parsedInput: { + workspaceId: "workspace-1", + name: "Support API", + }, + ctx: { user: { id: "intruder-1" } }, + }), + ).rejects.toThrow() + + expect(mocks.connect).not.toHaveBeenCalled() + }) }) diff --git a/apps/builder/__tests__/integrations-public-api.test.ts b/apps/builder/__tests__/integrations-public-api.test.ts index 47e2bba7bc..07df3cd738 100644 --- a/apps/builder/__tests__/integrations-public-api.test.ts +++ b/apps/builder/__tests__/integrations-public-api.test.ts @@ -58,6 +58,31 @@ vi.mock("@chatbotx.io/business/errors", () => ({ notFoundException: vi.fn( (message: string) => new MockChatbotXException(message, "notFound"), ), + validationException: vi.fn( + (_field: string, message: string) => + new MockChatbotXException(message, "validation"), + ), +})) + +const aiIntegrationService = { + invalidateCache: vi.fn(), +} + +vi.mock("@chatbotx.io/ai/server", () => ({ aiIntegrationService })) +vi.mock("@chatbotx.io/ai", () => ({ + aiProviders: { + enum: { + claude: "claude", + deepseek: "deepseek", + gemini: "gemini", + openai: "openai", + }, + }, +})) + +const verifyAiProviderApiKey = vi.fn(async () => true) +vi.mock("@/features/integration-ai/lib/verify-api-key", () => ({ + verifyAiProviderApiKey, })) const integrationService = { @@ -110,6 +135,7 @@ const findProcedure = (method: string, path: string) => { beforeEach(() => { vi.clearAllMocks() + verifyAiProviderApiKey.mockResolvedValue(true) }) describe("GET /v1/integrations", () => { @@ -297,6 +323,54 @@ describe("PUT /v1/integrations/ai/{provider}", () => { }) expect(JSON.stringify(result)).not.toContain("sk-gemini-secret") }) + + test("invalidates the AI integration cache after connecting", async () => { + integrationGeminiService.connect.mockResolvedValueOnce(undefined) + integrationGeminiService.findByWorkspaceId.mockResolvedValueOnce({ + id: "gemini-1", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + autoReply: false, + auth: { authType: "secretText", secretText: "sk-gemini-secret" }, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "sk-gemini-secret", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }) + + expect(aiIntegrationService.invalidateCache).toHaveBeenCalledWith( + "workspace-1", + "gemini", + ) + }) + + test("rejects an invalid API key without persisting it", async () => { + verifyAiProviderApiKey.mockResolvedValueOnce(false) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + provider: "gemini", + apiKey: "bad-key", + model: "gemini-3.5-flash", + temperature: 0.4, + maxOutputTokens: 1024, + }, + }), + ).rejects.toThrow() + + expect(integrationGeminiService.connect).not.toHaveBeenCalled() + expect(aiIntegrationService.invalidateCache).not.toHaveBeenCalled() + }) }) describe("DELETE /v1/integrations/ai/{provider}", () => { @@ -314,4 +388,22 @@ describe("DELETE /v1/integrations/ai/{provider}", () => { "workspace-1", ) }) + + test("invalidates the AI integration cache after disconnecting", async () => { + integrationDeepSeekService.disconnect.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { provider: "deepseek" }, + }) + + expect(aiIntegrationService.invalidateCache).toHaveBeenCalledWith( + "workspace-1", + "deepseek", + ) + }) + + test("responds with 204 (no body)", () => { + expect(procedure.route.successStatus).toBe(204) + }) }) diff --git a/apps/builder/src/features/integration-ai/lib/verify-api-key.ts b/apps/builder/src/features/integration-ai/lib/verify-api-key.ts index e9c692853c..cdb297e880 100644 --- a/apps/builder/src/features/integration-ai/lib/verify-api-key.ts +++ b/apps/builder/src/features/integration-ai/lib/verify-api-key.ts @@ -5,33 +5,37 @@ const VERIFY_TIMEOUT_MS = 10_000 const UNAUTHORIZED_STATUSES = new Set([401, 403]) type VerifyConfig = { - url: string - headers: (apiKey: string) => Record + url: (apiKey: string) => string + headers?: (apiKey: string) => Record } // Lightweight "list models" probes used purely to validate an API key. const verifyConfigByProvider: Partial> = { [aiProviders.enum.claude]: { - url: "https://api.anthropic.com/v1/models", + url: () => "https://api.anthropic.com/v1/models", headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01", }), }, [aiProviders.enum.deepseek]: { - url: "https://api.deepseek.com/models", + url: () => "https://api.deepseek.com/models", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), }, + [aiProviders.enum.gemini]: { + url: (apiKey) => + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, + }, [aiProviders.enum.openai]: { - url: "https://api.openai.com/v1/models", + url: () => "https://api.openai.com/v1/models", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), }, [aiProviders.enum.openrouter]: { - url: "https://openrouter.ai/api/v1/key", + url: () => "https://openrouter.ai/api/v1/key", headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, }), @@ -56,8 +60,8 @@ export async function verifyAiProviderApiKey( } try { - await ky.get(config.url, { - headers: config.headers(apiKey), + await ky.get(config.url(apiKey), { + headers: config.headers?.(apiKey), timeout: VERIFY_TIMEOUT_MS, retry: 0, }) diff --git a/apps/builder/src/features/integration-api/actions/create-api.action.ts b/apps/builder/src/features/integration-api/actions/create-api.action.ts index 473df30765..9fc71380d6 100644 --- a/apps/builder/src/features/integration-api/actions/create-api.action.ts +++ b/apps/builder/src/features/integration-api/actions/create-api.action.ts @@ -2,9 +2,11 @@ import { assertPublicUrl, + hasWorkspaceAccess, integrationApiService, workspaceService, } from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" import { generateApiChannelToken, generateSigningSecret, @@ -24,6 +26,9 @@ export const createApiAction = authActionClient let ownerId = ctx.user.id if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) diff --git a/apps/builder/src/features/integration-claude/actions/update.action.ts b/apps/builder/src/features/integration-claude/actions/update.action.ts index 08cb7b89b9..dc92cf1b83 100644 --- a/apps/builder/src/features/integration-claude/actions/update.action.ts +++ b/apps/builder/src/features/integration-claude/actions/update.action.ts @@ -24,7 +24,7 @@ export const updateIntegrationClaudeAction = workspaceActionClient parsedInput: UpdateClaudeRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - await integrationClaudeService.update(workspaceId, parsedInput) + await integrationClaudeService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, diff --git a/apps/builder/src/features/integration-deepseek/actions/update.action.ts b/apps/builder/src/features/integration-deepseek/actions/update.action.ts index a625615b27..fbe705ab12 100644 --- a/apps/builder/src/features/integration-deepseek/actions/update.action.ts +++ b/apps/builder/src/features/integration-deepseek/actions/update.action.ts @@ -24,7 +24,7 @@ export const updateIntegrationDeepSeekAction = workspaceActionClient parsedInput: UpdateDeepSeekRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - await integrationDeepSeekService.update(workspaceId, parsedInput) + await integrationDeepSeekService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache( workspaceId, diff --git a/apps/builder/src/features/integration-gemini/actions/connect.action.ts b/apps/builder/src/features/integration-gemini/actions/connect.action.ts index 3aab8a7e6a..d8b336e093 100644 --- a/apps/builder/src/features/integration-gemini/actions/connect.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/connect.action.ts @@ -1,4 +1,5 @@ "use server" +import { aiProviders } from "@chatbotx.io/ai" import { aiIntegrationService } from "@chatbotx.io/ai/server" import { integrationGeminiService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" @@ -7,8 +8,8 @@ import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" import { workspaceActionClient } from "@/lib/safe-action" -import { verifyGeminiApiKey } from "../lib" import { type ConnectGeminiRequest, connectGeminiRequest, @@ -27,7 +28,12 @@ export const connectGeminiAction = workspaceActionClient }) => { const t = await getTranslations() - if (!(await verifyGeminiApiKey(parsedInput.apiKey))) { + if ( + !(await verifyAiProviderApiKey( + aiProviders.enum.gemini, + parsedInput.apiKey, + )) + ) { return returnValidationErrors(connectGeminiRequest, { apiKey: { _errors: [t("validation.invalidApiKey")], @@ -43,7 +49,10 @@ export const connectGeminiAction = workspaceActionClient maxOutputTokens: parsedInput.maxOutputTokens, }) - await aiIntegrationService.invalidateCache(workspaceId, "gemini") + await aiIntegrationService.invalidateCache( + workspaceId, + aiProviders.enum.gemini, + ) return }, diff --git a/apps/builder/src/features/integration-gemini/actions/update.action.ts b/apps/builder/src/features/integration-gemini/actions/update.action.ts index 3b630d2ec5..00038261fb 100644 --- a/apps/builder/src/features/integration-gemini/actions/update.action.ts +++ b/apps/builder/src/features/integration-gemini/actions/update.action.ts @@ -22,7 +22,7 @@ export const updateGeminiAction = workspaceActionClient parsedInput: UpdateGeminiRequest bindArgsParsedInputs: WorkspaceIdRequestParams }) => { - await integrationGeminiService.update(workspaceId, parsedInput) + await integrationGeminiService.update({ workspaceId }, parsedInput) await aiIntegrationService.invalidateCache(workspaceId, "gemini") }, diff --git a/apps/builder/src/features/integration-gemini/lib/index.ts b/apps/builder/src/features/integration-gemini/lib/index.ts deleted file mode 100644 index 7760c1767e..0000000000 --- a/apps/builder/src/features/integration-gemini/lib/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ky from "ky" - -export async function verifyGeminiApiKey(apiKey: string) { - try { - await ky.get( - `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, - ) - return true - } catch { - return false - } -} diff --git a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts index 8c9c02eed7..4e35fb5a32 100644 --- a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts @@ -11,9 +11,9 @@ import { workspaceIdrequestParams, } from "@/features/common/schema" import { logger } from "@/lib/log" -import { authActionClient } from "@/lib/safe-action" +import { workspaceActionClientAllowExpired } from "@/lib/safe-action" -export const disconnectGoogleSheetsAction = authActionClient +export const disconnectGoogleSheetsAction = workspaceActionClientAllowExpired .bindArgsSchemas(workspaceIdrequestParams) .action( async ({ diff --git a/apps/builder/src/features/integration-openai/actions/connect.action.ts b/apps/builder/src/features/integration-openai/actions/connect.action.ts index 0bfab1111d..b226942876 100644 --- a/apps/builder/src/features/integration-openai/actions/connect.action.ts +++ b/apps/builder/src/features/integration-openai/actions/connect.action.ts @@ -9,14 +9,14 @@ import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" -import { authActionClient } from "@/lib/safe-action" -import { verifyOpenAIApiKey } from "../lib" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" +import { workspaceActionClient } from "@/lib/safe-action" import { type ConnectOpenAISchema, connectOpenAISchema, } from "../schema/request" -export const connectOpenAIAction = authActionClient +export const connectOpenAIAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(connectOpenAISchema) .action( @@ -29,7 +29,12 @@ export const connectOpenAIAction = authActionClient }) => { const t = await getTranslations() - if (!(await verifyOpenAIApiKey(parsedInput.apiKey))) { + if ( + !(await verifyAiProviderApiKey( + aiProviders.enum.openai, + parsedInput.apiKey, + )) + ) { return returnValidationErrors(connectOpenAISchema, { apiKey: { _errors: [t("validation.invalidApiKey")], diff --git a/apps/builder/src/features/integration-openai/lib/index.ts b/apps/builder/src/features/integration-openai/lib/index.ts deleted file mode 100644 index 2d5e41323c..0000000000 --- a/apps/builder/src/features/integration-openai/lib/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import ky from "ky" - -export async function verifyOpenAIApiKey(apiKey: string) { - try { - await ky.get("https://api.openai.com/v1/models", { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - return true - } catch { - return false - } -} diff --git a/apps/builder/src/features/integration-telegram/actions/connect.action.ts b/apps/builder/src/features/integration-telegram/actions/connect.action.ts index 6995829203..ae2578e160 100644 --- a/apps/builder/src/features/integration-telegram/actions/connect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/connect.action.ts @@ -1,6 +1,7 @@ "use server" import { + hasWorkspaceAccess, telegramIntegrationService, userQuotaService, workspaceService, @@ -41,6 +42,9 @@ export const connectTelegramAction = authActionClient // Resolve ownerId before the transaction to avoid an extra read inside it let ownerId = ctx.user.id if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) 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 84411433da..05715d064c 100644 --- a/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-telegram/actions/disconnect.action.ts @@ -42,7 +42,6 @@ export const disconnectTelegramAction = workspaceActionClientAllowExpired await db.transaction(async (tx) => { await telegramIntegrationService.disconnect({ - workspaceId, id: integrationTelegram.id, tx, }) 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 6555bcd314..22bed0339d 100644 --- a/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts +++ b/apps/builder/src/features/integration-webchat/actions/create-webchat.action.ts @@ -1,11 +1,13 @@ "use server" 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" @@ -34,6 +36,9 @@ export const createWebchatAction = authActionClient let createdWorkspace = false if (workspaceId) { + if (!(await hasWorkspaceAccess({ workspaceId, user: ctx.user }))) { + throw new ChatbotXException("Workspace not found", "notFound", 404) + } const workspace = await workspaceService.findOrFail({ where: { id: workspaceId }, }) diff --git a/apps/builder/src/features/integrations/api/public/ai.ts b/apps/builder/src/features/integrations/api/public/ai.ts index 6b318c4e38..9b630b4433 100644 --- a/apps/builder/src/features/integrations/api/public/ai.ts +++ b/apps/builder/src/features/integrations/api/public/ai.ts @@ -1,10 +1,16 @@ +import { aiProviders } from "@chatbotx.io/ai" +import { aiIntegrationService } from "@chatbotx.io/ai/server" import { integrationClaudeService, integrationDeepSeekService, integrationGeminiService, integrationOpenAIService, } from "@chatbotx.io/business" -import { notFoundException } from "@chatbotx.io/business/errors" +import { + notFoundException, + validationException, +} from "@chatbotx.io/business/errors" +import { verifyAiProviderApiKey } from "@/features/integration-ai/lib/verify-api-key" import { possibleErrorsOnFindingResource, possibleErrorsOnMutatingResource, @@ -93,6 +99,11 @@ export const integrationsAiPublicRouter = { .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const service = aiProviderServices[input.provider] + + if (!(await verifyAiProviderApiKey(input.provider, input.apiKey))) { + throw validationException("apiKey", "Invalid API key") + } + await service.connect({ workspaceId: context.workspace.id, apiKey: input.apiKey, @@ -100,6 +111,12 @@ export const integrationsAiPublicRouter = { temperature: input.temperature, maxOutputTokens: input.maxOutputTokens, }) + + await aiIntegrationService.invalidateCache( + context.workspace.id, + aiProviders.enum[input.provider], + ) + const row = await service.findByWorkspaceId(context.workspace.id) if (!row) { throw notFoundException(`${input.provider} integration not found`) @@ -113,11 +130,17 @@ export const integrationsAiPublicRouter = { path: "/v1/integrations/ai/{provider}", summary: "Disconnect an AI provider integration", tags: ["Integrations"], + successStatus: 204, }) .input(getAiProviderRequest) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const service = aiProviderServices[input.provider] await service.disconnect(context.workspace.id) + + await aiIntegrationService.invalidateCache( + context.workspace.id, + aiProviders.enum[input.provider], + ) }), } diff --git a/apps/worker/__tests__/coexist-instagram-sync.test.ts b/apps/worker/__tests__/coexist-instagram-sync.test.ts index 27ce07be6f..c1b663088a 100644 --- a/apps/worker/__tests__/coexist-instagram-sync.test.ts +++ b/apps/worker/__tests__/coexist-instagram-sync.test.ts @@ -44,7 +44,7 @@ const { vi.mock("@chatbotx.io/business", () => ({ coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findIntegrationForCoexist: mockFindIntegration, findResumeCeiling: mockFindResumeCeiling, findRunById: mockFindRunById, diff --git a/apps/worker/__tests__/coexist-messenger-sync.test.ts b/apps/worker/__tests__/coexist-messenger-sync.test.ts index 96bd7a3b4b..7daf4790d5 100644 --- a/apps/worker/__tests__/coexist-messenger-sync.test.ts +++ b/apps/worker/__tests__/coexist-messenger-sync.test.ts @@ -77,7 +77,7 @@ vi.mock("@chatbotx.io/business", () => ({ findLastSyncedAt: mockFindLastSyncedAt, findInitState: mockFindInitState, findResumeCeiling: mockFindResumeCeiling, - claimRunForSync: mockClaimRunForSync, + reclaimRunForRetry: mockClaimRunForSync, updateProgress: mockUpdateProgress, incrementProgress: mockIncrementProgress, findTerminalCounters: mockFindTerminalCounters, @@ -270,7 +270,7 @@ const wireSelectChain = ( } /** - * Wires `coexistService.claimRunForSync` — the optimistic claim that replaces + * Wires `coexistService.reclaimRunForRetry` — the optimistic claim that replaces * the old raw `db.update().set().where().returning()` chain. Default claim * result is the run row (handler treats the run as successfully claimed); * tests that need "already claimed" pass `wireUpdateChain(null)`. diff --git a/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts index 670ba93a6a..fcef438288 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts @@ -42,7 +42,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, @@ -783,7 +783,7 @@ describe("coexistWhatsappFlush — run lifecycle", () => { }) // ── the chunk chain must hand the run back before queueing the next one ── - // `claimRun` refuses a `running` run whose heartbeat is under 10 minutes + // `claimRunWithNewToken` refuses a `running` run whose heartbeat is under 10 minutes // old. A continuation queued while this worker still held the claim // therefore lost its own claim and abandoned, leaving the chain to the // scheduler's 1-hour stale sweep — one chunk per hour, then `failed`. diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts index ab7f48a2e4..6069bd71bb 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.payloads.test.ts @@ -81,7 +81,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts b/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts index c1e24797c0..11b196bfd9 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.test-utils.ts @@ -171,7 +171,7 @@ export function createFlushHarness(mocks: FlushHarnessMocks) { /** * Wires the production call graph: * 1. `integrationWhatsappRepository.findByPhoneNumberId` (select … limit 1) - * 2. `coexistService.claimRun` → the claimed run row + ownership token + * 2. `coexistService.claimRunWithNewToken` → the claimed run row + ownership token * 3. `whatsappCoexistStagingRepository.listPending` (select … orderBy … limit) * * The staged select returns `stagedRows` on the first batch call and `[]` on diff --git a/apps/worker/__tests__/coexist-whatsapp-flush.test.ts b/apps/worker/__tests__/coexist-whatsapp-flush.test.ts index 6b401ed079..0f647e1c88 100644 --- a/apps/worker/__tests__/coexist-whatsapp-flush.test.ts +++ b/apps/worker/__tests__/coexist-whatsapp-flush.test.ts @@ -81,7 +81,7 @@ vi.mock("@chatbotx.io/business/coexist", () => { mockRunWrite(fields, guard) return { coexistService: { - claimRun: mockClaimRun, + claimRunWithNewToken: mockClaimRun, findLiveRun: mockFindLiveRun, updateProgress: ({ fields, diff --git a/apps/worker/__tests__/inbox-labels.test.ts b/apps/worker/__tests__/inbox-labels.test.ts index 5ed9b4c478..710663fa7e 100644 --- a/apps/worker/__tests__/inbox-labels.test.ts +++ b/apps/worker/__tests__/inbox-labels.test.ts @@ -10,19 +10,19 @@ const state = { contactInboxes: [] as { id: string; contactId: string }[], ensureTagByNameResult: undefined as string | undefined, ensureTagChannelResult: undefined as string | undefined, - linkTagToContactsReturningNewResult: [] as { contactId: string }[], + linkTagToContactsReturningNewUnscopedResult: [] as { contactId: string }[], } // --------------------------------------------------------------------------- // Mock: @chatbotx.io/business — tagService, tagSyncService, messenger/zalo // integration services // --------------------------------------------------------------------------- -const linkTagToContactsReturningNew = vi.fn( - async () => state.linkTagToContactsReturningNewResult, +const linkTagToContactsReturningNewUnscoped = vi.fn( + async () => state.linkTagToContactsReturningNewUnscopedResult, ) -const recordTagChannelAssignments = vi.fn(async () => undefined) -const deleteTagChannelAssignments = vi.fn(async () => undefined) -const detachTagFromContacts = vi.fn(async () => undefined) +const recordTagChannelAssignmentsUnscoped = vi.fn(async () => undefined) +const deleteTagChannelAssignmentsUnscoped = vi.fn(async () => undefined) +const detachTagFromContactsUnscoped = vi.fn(async () => undefined) const findTagChannel = vi.fn(async () => state.tagChannel) const ensureTagByName = vi.fn(async () => state.ensureTagByNameResult) const ensureTagChannel = vi.fn(async () => state.ensureTagChannelResult) @@ -34,14 +34,14 @@ const zaloFindByOaId = vi.fn(async () => state.zaloIntegration) vi.mock("@chatbotx.io/business", () => ({ tagService: { - linkTagToContactsReturningNew: (...args: unknown[]) => - linkTagToContactsReturningNew(...args), - recordTagChannelAssignments: (...args: unknown[]) => - recordTagChannelAssignments(...args), - deleteTagChannelAssignments: (...args: unknown[]) => - deleteTagChannelAssignments(...args), - detachTagFromContacts: (...args: unknown[]) => - detachTagFromContacts(...args), + linkTagToContactsReturningNewUnscoped: (...args: unknown[]) => + linkTagToContactsReturningNewUnscoped(...args), + recordTagChannelAssignmentsUnscoped: (...args: unknown[]) => + recordTagChannelAssignmentsUnscoped(...args), + deleteTagChannelAssignmentsUnscoped: (...args: unknown[]) => + deleteTagChannelAssignmentsUnscoped(...args), + detachTagFromContactsUnscoped: (...args: unknown[]) => + detachTagFromContactsUnscoped(...args), findTagChannel: (...args: unknown[]) => findTagChannel(...args), ensureTagByName: (...args: unknown[]) => ensureTagByName(...args), ensureTagChannel: (...args: unknown[]) => ensureTagChannel(...args), @@ -165,7 +165,7 @@ beforeEach(() => { state.contactInboxes = [] state.ensureTagByNameResult = undefined state.ensureTagChannelResult = undefined - state.linkTagToContactsReturningNewResult = [] + state.linkTagToContactsReturningNewUnscopedResult = [] idCounter = 0 vi.clearAllMocks() @@ -179,8 +179,8 @@ beforeEach(() => { ) ensureTagByName.mockImplementation(async () => state.ensureTagByNameResult) ensureTagChannel.mockImplementation(async () => state.ensureTagChannelResult) - linkTagToContactsReturningNew.mockImplementation( - async () => state.linkTagToContactsReturningNewResult, + linkTagToContactsReturningNewUnscoped.mockImplementation( + async () => state.linkTagToContactsReturningNewUnscopedResult, ) }) @@ -200,7 +200,7 @@ describe("handleChannelLabelWebhook — dispatch", () => { expect.objectContaining({ channel: "telegram" }), "inbox labels: unsupported channel", ) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("stops when integration is not found", async () => { @@ -212,7 +212,7 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() expect(loggerWarn).not.toHaveBeenCalled() }) @@ -227,7 +227,7 @@ describe("handleChannelLabelWebhook — dispatch", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("warns on invalid payload", async () => { @@ -255,7 +255,7 @@ describe("handleChannelLabelWebhook — messenger", () => { test("add assigns + emits applied when the tag channel already exists", async () => { state.tagChannel = { id: "tc-1", tagId: "tag-1" } state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] - state.linkTagToContactsReturningNewResult = [{ contactId: "c-1" }] // newly linked + state.linkTagToContactsReturningNewUnscopedResult = [{ contactId: "c-1" }] // newly linked await handleChannelLabelWebhook( messengerData({ @@ -265,11 +265,11 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", contactIds: ["c-1"], }) - expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", tagChannelId: "tc-1", contactInboxIds: ["ci-1"], @@ -281,7 +281,7 @@ describe("handleChannelLabelWebhook — messenger", () => { state.tagChannel = undefined state.ensureTagByNameResult = "tag-new" state.ensureTagChannelResult = "tc-new" - state.linkTagToContactsReturningNewResult = [{ contactId: "c-1" }] + state.linkTagToContactsReturningNewUnscopedResult = [{ contactId: "c-1" }] state.contactInboxes = [{ id: "ci-1", contactId: "c-1" }] await handleChannelLabelWebhook( @@ -303,11 +303,11 @@ describe("handleChannelLabelWebhook — messenger", () => { integrationId: "intg-msg-1", externalLabelId: LABEL_ID, }) - expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ tagId: "tag-new", contactIds: ["c-1"], }) - expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ tagId: "tag-new", tagChannelId: "tc-new", contactInboxIds: ["ci-1"], @@ -327,7 +327,7 @@ describe("handleChannelLabelWebhook — messenger", () => { ) // page_label_name defaults to "" -> ensureTagByName is never a valid create path - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("add without user is a no-op", async () => { @@ -337,7 +337,7 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID, page_label_name: LABEL_NAME }, }), ) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("remove unassigns: deletes channel mapping + contact tag + emits removed", async () => { @@ -352,11 +352,11 @@ describe("handleChannelLabelWebhook — messenger", () => { }), ) - expect(deleteTagChannelAssignments).toHaveBeenCalledWith({ + expect(deleteTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ tagChannelId: "tc-1", contactInboxIds: ["ci-1"], }) - expect(detachTagFromContacts).toHaveBeenCalledWith({ + expect(detachTagFromContactsUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", contactIds: ["c-1"], }) @@ -367,7 +367,7 @@ describe("handleChannelLabelWebhook — messenger", () => { await handleChannelLabelWebhook( messengerData({ action: "remove", label: { id: LABEL_ID } }), ) - expect(deleteTagChannelAssignments).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("unknown action is a no-op", async () => { @@ -378,8 +378,8 @@ describe("handleChannelLabelWebhook — messenger", () => { label: { id: LABEL_ID }, }), ) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() - expect(deleteTagChannelAssignments).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) }) @@ -397,7 +397,7 @@ describe("handleChannelLabelWebhook — zalo", () => { { id: "ci-1", contactId: "c-1" }, { id: "ci-2", contactId: "c-2" }, ] - state.linkTagToContactsReturningNewResult = [ + state.linkTagToContactsReturningNewUnscopedResult = [ { contactId: "c-1" }, { contactId: "c-2" }, ] @@ -410,11 +410,11 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(linkTagToContactsReturningNew).toHaveBeenCalledWith({ + expect(linkTagToContactsReturningNewUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", contactIds: ["c-1", "c-2"], }) - expect(recordTagChannelAssignments).toHaveBeenCalledWith({ + expect(recordTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", tagChannelId: "tc-1", contactInboxIds: ["ci-1", "ci-2"], @@ -445,7 +445,7 @@ describe("handleChannelLabelWebhook — zalo", () => { integrationId: "intg-zalo-1", externalLabelId: LABEL_NAME, }) - expect(linkTagToContactsReturningNew).not.toHaveBeenCalled() + expect(linkTagToContactsReturningNewUnscoped).not.toHaveBeenCalled() }) test("remove_user_from_tag unassigns the batch", async () => { @@ -463,11 +463,11 @@ describe("handleChannelLabelWebhook — zalo", () => { }), ) - expect(deleteTagChannelAssignments).toHaveBeenCalledWith({ + expect(deleteTagChannelAssignmentsUnscoped).toHaveBeenCalledWith({ tagChannelId: "tc-1", contactInboxIds: ["ci-1", "ci-2"], }) - expect(detachTagFromContacts).toHaveBeenCalledWith({ + expect(detachTagFromContactsUnscoped).toHaveBeenCalledWith({ tagId: "tag-1", contactIds: ["c-1", "c-2"], }) @@ -486,7 +486,7 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME }, }), ) - expect(deleteTagChannelAssignments).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("remove_user_from_tag is a no-op when the tag channel is missing", async () => { @@ -498,7 +498,7 @@ describe("handleChannelLabelWebhook — zalo", () => { tag: { name: LABEL_NAME, user_ids: ["u-1"] }, }), ) - expect(deleteTagChannelAssignments).not.toHaveBeenCalled() + expect(deleteTagChannelAssignmentsUnscoped).not.toHaveBeenCalled() }) test("remove_tag enqueues a channel-scoped delete + keeps the workspace tag", async () => { @@ -519,7 +519,7 @@ describe("handleChannelLabelWebhook — zalo", () => { integrationId: "intg-zalo-1", }) // No workspace-wide tag delete from the webhook. - expect(detachTagFromContacts).not.toHaveBeenCalled() + expect(detachTagFromContactsUnscoped).not.toHaveBeenCalled() }) test("remove_tag is a no-op when the label is not mapped locally", async () => { diff --git a/apps/worker/__tests__/send-flow-step.test.ts b/apps/worker/__tests__/send-flow-step.test.ts index 844941c4c3..cfcdd46993 100644 --- a/apps/worker/__tests__/send-flow-step.test.ts +++ b/apps/worker/__tests__/send-flow-step.test.ts @@ -165,7 +165,7 @@ vi.mock("@chatbotx.io/business", () => ({ invalidateTracking: mockInvalidateTracking, }, conversationService: { - findByIdWithContact: mockFindConversation, + findByIdWithContactUnscoped: mockFindConversation, invalidate: mockConversationInvalidate, recordOutboundFlowStep: mockRecordOutboundFlowStep, recordOutboundMessageActivity: mockRecordOutboundMessageActivity, diff --git a/apps/worker/src/chat/handlers/send-flow-step.ts b/apps/worker/src/chat/handlers/send-flow-step.ts index fc1efb3562..735b7e3b5b 100644 --- a/apps/worker/src/chat/handlers/send-flow-step.ts +++ b/apps/worker/src/chat/handlers/send-flow-step.ts @@ -326,7 +326,7 @@ export async function sendFlowStep({ commentAnchor, appointmentId, }: ChatJobSendFlowStep["data"]) { - const conversation = await conversationService.findByIdWithContact({ + const conversation = await conversationService.findByIdWithContactUnscoped({ id: conversationId, }) if (!conversation) { diff --git a/apps/worker/src/integration/handlers/coexist/instagram-sync.ts b/apps/worker/src/integration/handlers/coexist/instagram-sync.ts index 6cf5defa65..77136304c2 100644 --- a/apps/worker/src/integration/handlers/coexist/instagram-sync.ts +++ b/apps/worker/src/integration/handlers/coexist/instagram-sync.ts @@ -65,7 +65,7 @@ const runInstagramCoexistPull = async < await failRun("Instagram integration not found or coexist disabled") return } - const claimed = await coexistService.claimRun({ runId }) + const claimed = await coexistService.claimRunWithNewToken({ runId }) if (!claimed) { logger.warn( { runId, integrationId }, diff --git a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts index 095ccb7a9a..ddaa90b3dc 100644 --- a/apps/worker/src/integration/handlers/coexist/messenger-sync.ts +++ b/apps/worker/src/integration/handlers/coexist/messenger-sync.ts @@ -667,7 +667,7 @@ export const coexistMessengerSync = async ( const attempts = initRow.attempts // Optimistic claim: only one worker may flip status→running at a time. - const claimedRun = await coexistService.claimRunForSync({ + const claimedRun = await coexistService.reclaimRunForRetry({ runId, touchUpdatedAt: true, }) diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts index 6c40771a2c..e645837c12 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-flush-context.ts @@ -171,7 +171,7 @@ export const loadFlushContext = async ( // Claim FIRST — avoids wasting the inbox lookup if another worker owns this // run. The claimed row carries the resume counters AND the fresh ownership // token, so no second read is needed. - const run = await coexistService.claimRun({ + const run = await coexistService.claimRunWithNewToken({ runId, fromStatuses: LIVE_RUN_STATUSES, }) diff --git a/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts b/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts index eff4c3bc6b..3d5464dfef 100644 --- a/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts +++ b/apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts @@ -404,7 +404,7 @@ const resolveFinalStatus = async ( /** * Hands the run back before the continuation is queued. * - * `claimRun` refuses a run that is `running` with a heartbeat under 10 minutes + * `claimRunWithNewToken` refuses a run that is `running` with a heartbeat under 10 minutes * old — that is what stops two workers driving one run. A continuation * enqueued while this worker still holds the claim therefore loses its own * claim and abandons, so the chunk chain has to release ownership first: back @@ -577,7 +577,7 @@ const logChunkComplete = ( * popup. Idempotent: safe to re-run as more history arrives over the ~24h * window Meta uses to push it. * - * EXCLUSIVE OWNERSHIP. `claimRun` mints a `claimToken` on the run; every write + * EXCLUSIVE OWNERSHIP. `claimRunWithNewToken` mints a `claimToken` on the run; every write * this handler makes afterwards is conditional on BOTH `status = 'running'` and * that token. A write that reports 0 rows therefore covers both ways the run * can move out from under us — a `disconnect`/`disable`/workspace teardown that diff --git a/apps/worker/src/integration/handlers/inbox_labels/sync.ts b/apps/worker/src/integration/handlers/inbox_labels/sync.ts index 92f9264382..e21f4de6f8 100644 --- a/apps/worker/src/integration/handlers/inbox_labels/sync.ts +++ b/apps/worker/src/integration/handlers/inbox_labels/sync.ts @@ -43,13 +43,13 @@ async function assignLabel( // Link the workspace tag to the contacts; capture the newly-linked ones so we // emit "tag applied" exactly once per new pair (same as add-contact-tag). - const linked = await tagService.linkTagToContactsReturningNew({ + const linked = await tagService.linkTagToContactsReturningNewUnscoped({ tagId: mapping.tagId, contactIds: inboxes.map((inbox) => inbox.contactId), }) // Record the per-channel assignment (used for reconciliation / detach). - await tagService.recordTagChannelAssignments({ + await tagService.recordTagChannelAssignmentsUnscoped({ tagId: mapping.tagId, tagChannelId: mapping.tagChannelId, contactInboxIds: inboxes.map((inbox) => inbox.id), @@ -93,14 +93,14 @@ async function unassignLabel( } // Remove the per-channel assignment record. - await tagService.deleteTagChannelAssignments({ + await tagService.deleteTagChannelAssignmentsUnscoped({ tagChannelId: tagChannel.id, contactInboxIds: inboxes.map((inbox) => inbox.id), }) // Remove the workspace tag from those contacts — same as remove-contact-tag. const contactIds = inboxes.map((inbox) => inbox.contactId) - await tagService.detachTagFromContacts({ + await tagService.detachTagFromContactsUnscoped({ tagId: tagChannel.tagId, contactIds, }) diff --git a/apps/worker/src/integration/handlers/messenger-template-handler.ts b/apps/worker/src/integration/handlers/messenger-template-handler.ts index fb6b0e9d66..ccaa066575 100644 --- a/apps/worker/src/integration/handlers/messenger-template-handler.ts +++ b/apps/worker/src/integration/handlers/messenger-template-handler.ts @@ -1,6 +1,6 @@ import { inboxService, - messengerIntegrationService, + messengerMessageTemplateService, } from "@chatbotx.io/business" import type { MessengerMessageTemplateModel } from "@chatbotx.io/database/types" import type { MessengerTemplateParams } from "@chatbotx.io/flow-config" @@ -50,7 +50,9 @@ export async function replaceMessengerTemplateVariables(props: { } type InboxWithIntegrationMessenger = NonNullable< - Awaited> + Awaited< + ReturnType + > > export type ValidatedMessengerTemplate = { @@ -73,7 +75,7 @@ export async function validateMessengerTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await inboxService.findWithIntegrationMessengerById({ + const inbox = await inboxService.findWithIntegrationMessengerByIdUnscoped({ id: inboxId, }) @@ -82,9 +84,10 @@ export async function validateMessengerTemplate( } const template = - await messengerIntegrationService.findApprovedMessengerTemplate({ + await messengerMessageTemplateService.findApprovedByIdForIntegration({ id: templateId, integrationMessengerId: inbox.integrationMessenger.id, + workspaceId: inbox.workspaceId, }) if (!template) { diff --git a/apps/worker/src/integration/handlers/wa-template-handler.ts b/apps/worker/src/integration/handlers/wa-template-handler.ts index 5701f32a54..43252ce3ab 100644 --- a/apps/worker/src/integration/handlers/wa-template-handler.ts +++ b/apps/worker/src/integration/handlers/wa-template-handler.ts @@ -1,4 +1,7 @@ -import { inboxService, integrationWhatsappService } from "@chatbotx.io/business" +import { + inboxService, + whatsappMessageTemplateService, +} from "@chatbotx.io/business" import type { IntegrationWhatsappModel, WhatsappMessageTemplateModel, @@ -78,7 +81,9 @@ export async function replaceWhatsappTemplateVariables(props: { export type ValidatedWhatsappTemplate = { inbox: NonNullable< - Awaited> + Awaited< + ReturnType + > > & { integrationWhatsapp: IntegrationWhatsappModel } @@ -89,7 +94,7 @@ export async function validateWhatsappTemplate( templateId: string, inboxId: string, ): Promise { - const inbox = await inboxService.findWithIntegrationWhatsappById({ + const inbox = await inboxService.findWithIntegrationWhatsappByIdUnscoped({ id: inboxId, }) @@ -98,9 +103,10 @@ export async function validateWhatsappTemplate( } const template = - await integrationWhatsappService.findApprovedWhatsappTemplate({ + await whatsappMessageTemplateService.findApprovedByIdForIntegration({ id: templateId, integrationWhatsappId: inbox.integrationWhatsapp.id, + workspaceId: inbox.workspaceId, }) if (!template) { diff --git a/packages/business/__tests__/coexist.service.test.ts b/packages/business/__tests__/coexist.service.test.ts index fcd0ecb631..2d0769086c 100644 --- a/packages/business/__tests__/coexist.service.test.ts +++ b/packages/business/__tests__/coexist.service.test.ts @@ -17,7 +17,7 @@ vi.mock("@chatbotx.io/database/client", () => ({ vi.mock("@chatbotx.io/database/repositories", () => ({ coexistSyncRunRepository: { - claimRun: vi.fn(), + claimRunWithNewToken: vi.fn(), createRun: mocks.createRun, findIntegrationForCoexist: mocks.findIntegrationForCoexist, findLiveRun: mocks.findLiveRun, diff --git a/packages/business/__tests__/contact-service-flow-flags.test.ts b/packages/business/__tests__/contact-service-flow-flags.test.ts index 1d83aaad48..772f5e75f3 100644 --- a/packages/business/__tests__/contact-service-flow-flags.test.ts +++ b/packages/business/__tests__/contact-service-flow-flags.test.ts @@ -211,7 +211,10 @@ describe("contactService.setFlowFlags", () => { expect(client.set).toHaveBeenCalledWith({ emailVerified: true }) expect(client.where).toHaveBeenCalledWith({ - __eq: ["contact.id", "contact-1"], + __and: [ + { __eq: ["contact.id", "contact-1"] }, + { __eq: ["contact.workspaceId", "ws-1"] }, + ], }) // The raw worker write skipped invalidation, leaving a stale cached // contact behind; routing through the service fixes that. diff --git a/packages/business/src/coexist/service.ts b/packages/business/src/coexist/service.ts index 873e7bd002..a99a8b8151 100644 --- a/packages/business/src/coexist/service.ts +++ b/packages/business/src/coexist/service.ts @@ -173,11 +173,11 @@ class CoexistService extends BaseService { * WhatsApp flush passes `LIVE_RUN_STATUSES` so a run parked in `waiting` is * claimable too. */ - claimRun(input: { + claimRunWithNewToken(input: { runId: string fromStatuses?: CoexistRunStatus[] }): Promise { - return coexistSyncRunRepository.claimRun(input) + return coexistSyncRunRepository.claimRunWithNewToken(input) } findRunById(input: { runId: string }): Promise { @@ -326,11 +326,11 @@ class CoexistService extends BaseService { return coexistSyncRunRepository.findInitState(input) } - claimRunForSync(input: { + reclaimRunForRetry(input: { runId: string touchUpdatedAt: boolean }): Promise { - return coexistSyncRunRepository.claimRunForSync(input) + return coexistSyncRunRepository.reclaimRunForRetry(input) } findTerminalCounters(input: { diff --git a/packages/business/src/contact/service.ts b/packages/business/src/contact/service.ts index 480d9a19a3..b8ba341f01 100644 --- a/packages/business/src/contact/service.ts +++ b/packages/business/src/contact/service.ts @@ -906,7 +906,15 @@ class ContactService extends BaseService { data: Partial>, tx: DatabaseClient = db, ): Promise { - await tx.update(contactModel).set(data).where(eq(contactModel.id, ctx.id)) + await tx + .update(contactModel) + .set(data) + .where( + and( + eq(contactModel.id, ctx.id), + eq(contactModel.workspaceId, ctx.workspaceId), + ), + ) await this.invalidate({ workspaceId: ctx.workspaceId, ids: [ctx.id] }) } diff --git a/packages/business/src/conversation/service.ts b/packages/business/src/conversation/service.ts index d9de8f1175..6f339f8e15 100644 --- a/packages/business/src/conversation/service.ts +++ b/packages/business/src/conversation/service.ts @@ -1157,8 +1157,14 @@ class ConversationService extends BaseService { await this.invalidateCacheTags(tags) } - /** Conversation + contact, for the hot send-flow-step path (do NOT use `findWithFullRelations` here — it fetches far more). */ - async findByIdWithContact(props: { + /** + * Conversation + contact, for the hot send-flow-step path (do NOT use + * `findWithFullRelations` here — it fetches far more). Unscoped by `id` + * only — safe today because its sole caller (`send-flow-step.ts`) is the + * entry point that resolves the workspace *from* this conversation lookup, + * so no `workspaceId` exists yet to filter by. + */ + async findByIdWithContactUnscoped(props: { id: string tx?: DatabaseClient }): Promise< diff --git a/packages/business/src/inbox/service.ts b/packages/business/src/inbox/service.ts index 7112b5562b..05596a1c14 100644 --- a/packages/business/src/inbox/service.ts +++ b/packages/business/src/inbox/service.ts @@ -311,9 +311,12 @@ class InboxService extends BaseService { * the relation survives inference (a bare `typeof db.query.inboxModel * .findFirst` with no call resolves to the no-`with` overload and drops * the relation — see `messenger-template-handler.ts`'s prior local - * workaround). + * workaround). Unscoped by `id` only — safe today because its sole caller + * (`messenger-template-handler.ts`) receives `inboxId` from a + * webhook-resolved, already workspace-scoped context and has no + * `workspaceId` in scope to filter by. */ - async findWithIntegrationMessengerById(props: { + async findWithIntegrationMessengerByIdUnscoped(props: { id: string tx?: DatabaseClient }): Promise< @@ -327,8 +330,14 @@ class InboxService extends BaseService { }) } - /** Inbox + `integrationWhatsapp` relation — same explicit-return-type reasoning as above. */ - async findWithIntegrationWhatsappById(props: { + /** + * Inbox + `integrationWhatsapp` relation — same explicit-return-type + * reasoning as above. Unscoped by `id` only — safe today because its sole + * caller (`wa-template-handler.ts`) receives `inboxId` from a + * webhook-resolved, already workspace-scoped context and has no + * `workspaceId` in scope to filter by. + */ + async findWithIntegrationWhatsappByIdUnscoped(props: { id: string tx?: DatabaseClient }): Promise< diff --git a/packages/business/src/integration-claude/service.ts b/packages/business/src/integration-claude/service.ts index 2945f333c8..8e465c1507 100644 --- a/packages/business/src/integration-claude/service.ts +++ b/packages/business/src/integration-claude/service.ts @@ -34,20 +34,22 @@ class IntegrationClaudeService extends BaseService { ) } - async update(workspaceId: string, data: UpdateClaudeInput) { - const existing = await db.query.integrationClaudeModel.findFirst({ - where: { workspaceId }, - }) + async update(props: { workspaceId: string }, data: UpdateClaudeInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) if (!existing) { throw new Error("Integration Claude not found") } - await db + const result = await db .update(integrationClaudeModel) .set(data) .where(eq(integrationClaudeModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) await this.audit("update", "updated the Claude integration configuration") + + return result } async disconnect(workspaceId: string) { diff --git a/packages/business/src/integration-deepseek/service.ts b/packages/business/src/integration-deepseek/service.ts index 95e9655eb0..3a3bee10d4 100644 --- a/packages/business/src/integration-deepseek/service.ts +++ b/packages/business/src/integration-deepseek/service.ts @@ -36,20 +36,22 @@ class IntegrationDeepSeekService extends BaseService { ) } - async update(workspaceId: string, data: UpdateDeepSeekInput) { - const existing = await db.query.integrationDeepseekModel.findFirst({ - where: { workspaceId }, - }) + async update(props: { workspaceId: string }, data: UpdateDeepSeekInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) if (!existing) { throw new Error("Integration DeepSeek not found") } - await db + const result = await db .update(integrationDeepseekModel) .set(data) .where(eq(integrationDeepseekModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) await this.audit("update", "updated the DeepSeek integration configuration") + + return result } async disconnect(workspaceId: string) { diff --git a/packages/business/src/integration-gemini/service.ts b/packages/business/src/integration-gemini/service.ts index d0b025259f..c2eefc82e6 100644 --- a/packages/business/src/integration-gemini/service.ts +++ b/packages/business/src/integration-gemini/service.ts @@ -34,20 +34,22 @@ class IntegrationGeminiService extends BaseService { ) } - async update(workspaceId: string, data: UpdateGeminiInput) { - const existing = await db.query.integrationGeminiModel.findFirst({ - where: { workspaceId }, - }) + async update(props: { workspaceId: string }, data: UpdateGeminiInput) { + const existing = await this.findByWorkspaceId(props.workspaceId) if (!existing) { throw new Error("Integration Gemini not found") } - await db + const result = await db .update(integrationGeminiModel) .set(data) .where(eq(integrationGeminiModel.id, existing.id)) + .returning() + .then((rows) => rows[0]) await this.audit("update", "updated the Gemini integration configuration") + + return result } async disconnect(workspaceId: string) { diff --git a/packages/business/src/integration-messenger/service.ts b/packages/business/src/integration-messenger/service.ts index 960038b17d..a56ba7bf25 100644 --- a/packages/business/src/integration-messenger/service.ts +++ b/packages/business/src/integration-messenger/service.ts @@ -17,10 +17,7 @@ import { integrationMessengerModel, tagChannelModel, } from "@chatbotx.io/database/schema" -import type { - IntegrationMessengerModel, - MessengerMessageTemplateModel, -} from "@chatbotx.io/database/types" +import type { IntegrationMessengerModel } from "@chatbotx.io/database/types" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { @@ -276,20 +273,6 @@ class MessengerIntegrationService extends BaseService { return integrationMessengerRepository.findByPageIdUnscoped(props) } - /** Approved Messenger message template lookup for outbound template sends. */ - findApprovedMessengerTemplate(props: { - id: string - integrationMessengerId: string - }): Promise { - return db.query.messengerMessageTemplateModel.findFirst({ - where: { - id: props.id, - integrationMessengerId: props.integrationMessengerId, - status: "APPROVED", - }, - }) - } - listByWorkspaceIdOrId( where: Partial>, ) { diff --git a/packages/business/src/integration-telegram/service.ts b/packages/business/src/integration-telegram/service.ts index f473d0f47f..654aba5c9b 100644 --- a/packages/business/src/integration-telegram/service.ts +++ b/packages/business/src/integration-telegram/service.ts @@ -90,11 +90,7 @@ class TelegramIntegrationService extends BaseService { return { integrationId, wasCreated } } - async disconnect(props: { - workspaceId: string - id: string - tx: DatabaseClient - }) { + async disconnect(props: { id: string; tx: DatabaseClient }) { await props.tx .delete(integrationTelegramModel) .where(eq(integrationTelegramModel.id, props.id)) diff --git a/packages/business/src/integration-whatsapp/service.ts b/packages/business/src/integration-whatsapp/service.ts index 4ca1997411..f97f7303a0 100644 --- a/packages/business/src/integration-whatsapp/service.ts +++ b/packages/business/src/integration-whatsapp/service.ts @@ -1,7 +1,6 @@ import { and, type DatabaseClient, - db, eq, inArray, } from "@chatbotx.io/database/client" @@ -20,7 +19,6 @@ import { } from "@chatbotx.io/database/schema" import type { IntegrationWhatsappModel, - WhatsappMessageTemplateModel, WhatsappSignupSessionModel, } from "@chatbotx.io/database/types" import { encryptedDataSchema, encryptUtils } from "@chatbotx.io/encryption" @@ -644,20 +642,6 @@ class IntegrationWhatsappService extends BaseService { return integrationWhatsappRepository.markHistoryDeclined(props) } - /** Approved WhatsApp message template lookup for outbound template sends. */ - findApprovedWhatsappTemplate(props: { - id: string - integrationWhatsappId: string - }): Promise { - return db.query.whatsappMessageTemplateModel.findFirst({ - where: { - id: props.id, - integrationWhatsappId: props.integrationWhatsappId, - status: "APPROVED", - }, - }) - } - /** * Everything one disconnect must abandon or delete, in a single * transaction. Sync history (importedCount / lastSyncedAt / …) is diff --git a/packages/business/src/messenger-message-template/service.ts b/packages/business/src/messenger-message-template/service.ts index b74a00290e..ad3523eb62 100644 --- a/packages/business/src/messenger-message-template/service.ts +++ b/packages/business/src/messenger-message-template/service.ts @@ -160,6 +160,22 @@ class MessengerMessageTemplateService extends BaseService { }) } + /** Approved-only template lookup for outbound template sends, scoped by workspace through the integration relation. */ + findApprovedByIdForIntegration(props: { + id: string + integrationMessengerId: string + workspaceId: string + }) { + return db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationMessengerId: props.integrationMessengerId, + integrationMessenger: { workspaceId: props.workspaceId }, + status: "APPROVED", + }, + }) + } + async delete(props: { id: string integrationMessengerId: string diff --git a/packages/business/src/tag/service.ts b/packages/business/src/tag/service.ts index f52cc84b04..c39c5ae8d8 100644 --- a/packages/business/src/tag/service.ts +++ b/packages/business/src/tag/service.ts @@ -1032,8 +1032,14 @@ class TagService extends BaseService { } } - /** Unlink one workspace tag from many contacts (inbox-label unassign). */ - async detachTagFromContacts(props: { + /** + * Unlink one workspace tag from many contacts (inbox-label unassign). + * `ContactToTag` has no `workspaceId` column, so this cannot be scoped + * without an extra join — safe today because every caller + * (`inbox_labels/sync.ts`) resolves `tagId`/`contactIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. + */ + async detachTagFromContactsUnscoped(props: { tagId: string contactIds: string[] tx?: DatabaseClient @@ -1055,9 +1061,12 @@ class TagService extends BaseService { /** * Link a workspace tag to many contacts, returning the NEWLY-linked * contact ids (untargeted `onConflictDoNothing()` — verbatim from - * `inbox_labels/sync.ts` `assignLabel`). + * `inbox_labels/sync.ts` `assignLabel`). `ContactToTag` has no + * `workspaceId` column, so this cannot be scoped without an extra join — + * safe today because the caller resolves `tagId`/`contactIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. */ - async linkTagToContactsReturningNew(props: { + async linkTagToContactsReturningNewUnscoped(props: { tagId: string contactIds: string[] tx?: DatabaseClient @@ -1073,8 +1082,15 @@ class TagService extends BaseService { .returning({ contactId: contactsToTagsModel.contactId }) } - /** Record per-channel tag assignments (used for reconciliation / detach). */ - async recordTagChannelAssignments(props: { + /** + * Record per-channel tag assignments (used for reconciliation / detach). + * `ContactToTagChannel` has no `workspaceId` column, so this cannot be + * scoped without an extra join — safe today because the caller + * (`inbox_labels/sync.ts`) resolves `tagId`/`tagChannelId`/ + * `contactInboxIds` from a workspace-scoped `ensureTagChannel` + + * `ctx.inboxId` lookup first. + */ + async recordTagChannelAssignmentsUnscoped(props: { tagId: string tagChannelId: string contactInboxIds: string[] @@ -1096,8 +1112,14 @@ class TagService extends BaseService { .onConflictDoNothing() } - /** Remove per-channel tag assignments (inbox-label unassign). */ - async deleteTagChannelAssignments(props: { + /** + * Remove per-channel tag assignments (inbox-label unassign). + * `ContactToTagChannel` has no `workspaceId` column, so this cannot be + * scoped without an extra join — safe today because the caller + * (`inbox_labels/sync.ts`) resolves `tagChannelId`/`contactInboxIds` from a + * workspace-scoped `ensureTagChannel` + `ctx.inboxId` lookup first. + */ + async deleteTagChannelAssignmentsUnscoped(props: { tagChannelId: string contactInboxIds: string[] tx?: DatabaseClient diff --git a/packages/business/src/whatsapp-message-template/service.ts b/packages/business/src/whatsapp-message-template/service.ts index 4760cb274f..f38c93b47e 100644 --- a/packages/business/src/whatsapp-message-template/service.ts +++ b/packages/business/src/whatsapp-message-template/service.ts @@ -48,6 +48,22 @@ class WhatsappMessageTemplateService extends BaseService { }) } + /** Approved-only template lookup for outbound template sends, scoped by workspace through the integration relation. */ + findApprovedByIdForIntegration(props: { + id: string + integrationWhatsappId: string + workspaceId: string + }) { + return db.query.whatsappMessageTemplateModel.findFirst({ + where: { + id: props.id, + integrationWhatsappId: props.integrationWhatsappId, + integrationWhatsapp: { workspaceId: props.workspaceId }, + status: "APPROVED", + }, + }) + } + /** Full sync only — WhatsApp's template sync has no partial-match mode. */ async syncFromMeta(props: { integrationWhatsappId: string diff --git a/packages/database/__tests__/coexist-run-recovery-repository.test.ts b/packages/database/__tests__/coexist-run-recovery-repository.test.ts index 778dcbace7..7fa95a6dc0 100644 --- a/packages/database/__tests__/coexist-run-recovery-repository.test.ts +++ b/packages/database/__tests__/coexist-run-recovery-repository.test.ts @@ -364,14 +364,14 @@ describe("CoexistSyncRunRepository recovery queries", () => { // F5/M3: one claim for every channel — `fromStatuses` is the only // difference (WhatsApp may claim a run parked in `waiting`). - test("claimRun mints a fresh ownership token and returns the claimed row", async () => { + test("claimRunWithNewToken mints a fresh ownership token and returns the claimed row", async () => { const { tx, set } = wireUpdate([ { id: "run-1", claimToken: "generated" } as never, ]) const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ runId: "run-1", tx }), + repository.claimRunWithNewToken({ runId: "run-1", tx }), ).resolves.toEqual({ id: "run-1", claimToken: "generated" }) const written = set.mock.calls[0]?.[0] as Record @@ -387,7 +387,7 @@ describe("CoexistSyncRunRepository recovery queries", () => { const tokens: unknown[] = [] for (let i = 0; i < 2; i += 1) { const { tx, set } = wireUpdate([{ id: "run-1" }]) - await repository.claimRun({ runId: "run-1", tx }) + await repository.claimRunWithNewToken({ runId: "run-1", tx }) tokens.push( (set.mock.calls[0]?.[0] as Record).claimToken, ) @@ -396,11 +396,11 @@ describe("CoexistSyncRunRepository recovery queries", () => { expect(new Set(tokens).size).toBe(2) }) - test("claimRun widens to the live statuses when asked (WhatsApp)", async () => { + test("claimRunWithNewToken widens to the live statuses when asked (WhatsApp)", async () => { const { tx } = wireUpdate([{ id: "run-1" }]) const repository = new CoexistSyncRunRepository() - await repository.claimRun({ + await repository.claimRunWithNewToken({ runId: "run-1", fromStatuses: LIVE_RUN_STATUSES, tx, @@ -413,12 +413,12 @@ describe("CoexistSyncRunRepository recovery queries", () => { ]) }) - test("claimRun returns null when another worker holds the run", async () => { + test("claimRunWithNewToken returns null when another worker holds the run", async () => { const { tx } = wireUpdate([]) const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ runId: "run-1", tx }), + repository.claimRunWithNewToken({ runId: "run-1", tx }), ).resolves.toBeNull() }) }) diff --git a/packages/database/__tests__/coexist-sync-run-repository.test.ts b/packages/database/__tests__/coexist-sync-run-repository.test.ts index 5f476b4fc2..38058d0868 100644 --- a/packages/database/__tests__/coexist-sync-run-repository.test.ts +++ b/packages/database/__tests__/coexist-sync-run-repository.test.ts @@ -70,7 +70,7 @@ describe("CoexistSyncRunRepository", () => { mocks.isUniqueViolationError.mockReturnValue(false) }) - test("claimRun only claims active init/running runs", async () => { + test("claimRunWithNewToken only claims active init/running runs", async () => { const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) const where = vi.fn(() => ({ returning })) const set = vi.fn(() => ({ where })) @@ -78,7 +78,7 @@ describe("CoexistSyncRunRepository", () => { const repository = new CoexistSyncRunRepository() await expect( - repository.claimRun({ + repository.claimRunWithNewToken({ runId: "run-1", tx: { update } as never, }), @@ -108,7 +108,7 @@ describe("CoexistSyncRunRepository", () => { // Attempts alone used to be enough, so a healthy multi-hour backfill that // burned its retries was terminalized mid-import — taking its pending - // media patches with it. The same 10-minute staleness `claimRun` uses now + // media patches with it. The same 10-minute staleness `claimRunWithNewToken` uses now // gates it, so only a run nobody is driving can be failed. expect(mocks.isNull).toHaveBeenCalledWith("lastHeartbeatAt") expect(mocks.lt).toHaveBeenCalledWith("lastHeartbeatAt", expect.anything()) @@ -242,12 +242,12 @@ describe("CoexistSyncRunRepository", () => { }) // --- Regression guards for the worker data-access refactor ------------- - // `claimRunForSync` is deliberately NOT `claimRun`: the coexist sync claim + // `reclaimRunForRetry` is deliberately NOT `claimRunWithNewToken`: the coexist sync claim // omits the `status IN ('init','running')` filter so a retry can reclaim a // `failed`/`partial` run. Re-adding that filter silently breaks retry // recovery, so assert its absence explicitly. - test("claimRunForSync does NOT filter status IN ('init','running')", async () => { + test("reclaimRunForRetry does NOT filter status IN ('init','running')", async () => { const returning = vi.fn().mockResolvedValue([{ id: "run-1" }]) const where = vi.fn(() => ({ returning })) const set = vi.fn(() => ({ where })) @@ -255,7 +255,7 @@ describe("CoexistSyncRunRepository", () => { const repository = new CoexistSyncRunRepository() await expect( - repository.claimRunForSync({ + repository.reclaimRunForRetry({ runId: "run-1", touchUpdatedAt: true, tx: { update } as never, @@ -274,7 +274,7 @@ describe("CoexistSyncRunRepository", () => { ) }) - test("claimRunForSync touches updatedAt only when asked (messenger-sync yes, whatsapp-flush no)", async () => { + test("reclaimRunForRetry touches updatedAt only when asked (messenger-sync yes, whatsapp-flush no)", async () => { const repository = new CoexistSyncRunRepository() const makeTx = () => { @@ -285,7 +285,7 @@ describe("CoexistSyncRunRepository", () => { } const touched = makeTx() - await repository.claimRunForSync({ + await repository.reclaimRunForRetry({ runId: "run-1", touchUpdatedAt: true, tx: touched.tx, @@ -293,7 +293,7 @@ describe("CoexistSyncRunRepository", () => { expect(touched.set.mock.calls[0]?.[0]).toHaveProperty("updatedAt") const untouched = makeTx() - await repository.claimRunForSync({ + await repository.reclaimRunForRetry({ runId: "run-1", touchUpdatedAt: false, tx: untouched.tx, diff --git a/packages/database/src/repositories/coexist-sync-run/repository.ts b/packages/database/src/repositories/coexist-sync-run/repository.ts index b1d546d310..718561676c 100644 --- a/packages/database/src/repositories/coexist-sync-run/repository.ts +++ b/packages/database/src/repositories/coexist-sync-run/repository.ts @@ -52,7 +52,7 @@ export const LIVE_RUN_STATUSES: CoexistRunStatus[] = [ * Statuses the pull channels (Messenger/Instagram) claim from. They never enter * `waiting`, so their claim window is narrower than `LIVE_RUN_STATUSES` — the * one difference between the two claim call sites, expressed as data rather - * than as a second `claimRun` implementation. + * than as a second `claimRunWithNewToken` implementation. */ export const PULL_CLAIMABLE_STATUSES: CoexistRunStatus[] = ["init", "running"] @@ -315,7 +315,7 @@ export class CoexistSyncRunRepository { * * @returns the claimed row, or null when this worker did not win it. */ - async claimRun(input: { + async claimRunWithNewToken(input: { runId: string fromStatuses?: CoexistRunStatus[] tx?: DatabaseClient @@ -353,7 +353,7 @@ export class CoexistSyncRunRepository { /** * Terminalizes runs the scheduler has retried to exhaustion. * - * Gated on the same 10-minute staleness `claimRun` uses: a run being driven + * Gated on the same 10-minute staleness `claimRunWithNewToken` uses: a run being driven * right now heartbeats every batch, and a healthy multi-hour backfill that * happens to have burned its attempts must not be killed mid-import — that * would strand its `pendingPatches` along with it. Only a run nobody has @@ -772,13 +772,13 @@ export class CoexistSyncRunRepository { /** * Optimistic claim with a stale-heartbeat fallback, used by * `messenger-sync.ts` and `whatsapp-flush.ts`. Deliberately does NOT - * include `claimRun`'s `inArray(status, ["init","running"])` guard — both + * include `claimRunWithNewToken`'s `inArray(status, ["init","running"])` guard — both * callers reclaim `failed`/`partial` runs on retry, so adding that guard * would silently break retry recovery. `touchUpdatedAt` distinguishes the * two callers' SET clauses (messenger-sync also bumps `updatedAt`; * whatsapp-flush does not) — do not unify beyond this flag. */ - async claimRunForSync(input: { + async reclaimRunForRetry(input: { runId: string touchUpdatedAt: boolean tx?: DatabaseClient From 849acf2d1fcf9f0216c017e9b240932ab7f8644b Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 10:00:01 +0700 Subject: [PATCH 5/5] test: fix tests broken by tenant-scoping refactor Add required reason field to disconnect mock, and swap the schema mock in ref job tests for a Proxy sentinel since contactService now pulls in contact-filter queries that touch schema tables at module scope beyond the ones previously listed. --- .../disconnect-whatsapp-action.test.ts | 1 + .../__tests__/run-ref-minigame-share.test.ts | 21 ++++++++++++++----- .../worker/__tests__/run-ref-referral.test.ts | 21 ++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts index 257052a53f..f6ebcfd775 100644 --- a/apps/builder/__tests__/disconnect-whatsapp-action.test.ts +++ b/apps/builder/__tests__/disconnect-whatsapp-action.test.ts @@ -72,6 +72,7 @@ const integrationWhatsappServiceDisconnect = vi.fn( inboxId: props.integrationWhatsapp.inboxId, ownerId: props.ownerId, workspaceId: props.workspaceId, + reason: "manual", tx: props.tx, }) }, diff --git a/apps/worker/__tests__/run-ref-minigame-share.test.ts b/apps/worker/__tests__/run-ref-minigame-share.test.ts index c2e88dbc92..79852ada95 100644 --- a/apps/worker/__tests__/run-ref-minigame-share.test.ts +++ b/apps/worker/__tests__/run-ref-minigame-share.test.ts @@ -16,11 +16,22 @@ vi.mock("@chatbotx.io/business/minigame", () => ({ const findOrFail = vi.fn() vi.mock("@chatbotx.io/database/client", () => ({ findOrFail })) -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flowModel.id" }, - flowVersionModel: { id: "flowVersionModel.id" }, - reflinkModel: { id: "reflinkModel.id" }, -})) +// `ref.ts` -> business/minigame -> ...-> contactService pulls in +// `queries/contact-filter`, which touches many unrelated schema tables at +// module scope. A Proxy sentinel satisfies vitest's "does this export +// exist" check for any table name without listing the whole schema. +vi.mock("@chatbotx.io/database/schema", () => { + const overrides = { + flowModel: { id: "flowModel.id" }, + flowVersionModel: { id: "flowVersionModel.id" }, + reflinkModel: { id: "reflinkModel.id" }, + } + return new Proxy(overrides, { + get: (target, prop) => + prop in target ? target[prop as keyof typeof target] : {}, + has: () => true, + }) +}) const emit = vi.fn() vi.mock("@chatbotx.io/event-bus", () => ({ emit })) diff --git a/apps/worker/__tests__/run-ref-referral.test.ts b/apps/worker/__tests__/run-ref-referral.test.ts index 1b677d7707..8ab24db8f7 100644 --- a/apps/worker/__tests__/run-ref-referral.test.ts +++ b/apps/worker/__tests__/run-ref-referral.test.ts @@ -19,11 +19,22 @@ vi.mock("@chatbotx.io/database/client", () => ({ findOrFail, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flowModel.id" }, - flowVersionModel: { id: "flowVersionModel.id" }, - reflinkModel: { id: "reflinkModel.id" }, -})) +// `ref.ts` -> business/minigame -> ...-> contactService pulls in +// `queries/contact-filter`, which touches many unrelated schema tables at +// module scope. A Proxy sentinel satisfies vitest's "does this export +// exist" check for any table name without listing the whole schema. +vi.mock("@chatbotx.io/database/schema", () => { + const overrides = { + flowModel: { id: "flowModel.id" }, + flowVersionModel: { id: "flowVersionModel.id" }, + reflinkModel: { id: "reflinkModel.id" }, + } + return new Proxy(overrides, { + get: (target, prop) => + prop in target ? target[prop as keyof typeof target] : {}, + has: () => true, + }) +}) const emit = vi.fn() vi.mock("@chatbotx.io/event-bus", () => ({ emit }))