diff --git a/apps/worker/__tests__/action-executor-contact-inbox-attribution.test.ts b/apps/worker/__tests__/action-executor-contact-inbox-attribution.test.ts index db115bfa3d..9a4e9c2b8f 100644 --- a/apps/worker/__tests__/action-executor-contact-inbox-attribution.test.ts +++ b/apps/worker/__tests__/action-executor-contact-inbox-attribution.test.ts @@ -12,14 +12,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // --------------------------------------------------------------------------- const mocks = vi.hoisted(() => ({ - conversationFindFirst: vi.fn(), - tagFindMany: vi.fn(), - flowFindFirst: vi.fn(), - workspaceMemberFindFirst: vi.fn(), - inboxTeamFindFirst: vi.fn(), + findLatestCreatedByContact: vi.fn(), + attachExistingToContactForTrigger: vi.fn(), + detachFromContactForTrigger: vi.fn(), + findActiveById: vi.fn(), findByIdForContact: vi.fn(), findMostRecentByContact: vi.fn(), - insertReturning: vi.fn(), enqueueEvent: vi.fn(), buildSourceKey: vi.fn(), setValues: vi.fn(), @@ -36,45 +34,7 @@ const mocks = vi.hoisted(() => ({ getSpreadsheetRow: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - conversationModel: { - findFirst: (...args: unknown[]) => mocks.conversationFindFirst(...args), - }, - tagModel: { - findMany: (...args: unknown[]) => mocks.tagFindMany(...args), - }, - flowModel: { - findFirst: (...args: unknown[]) => mocks.flowFindFirst(...args), - }, - workspaceMemberModel: { - findFirst: (...args: unknown[]) => - mocks.workspaceMemberFindFirst(...args), - }, - inboxTeamModel: { - findFirst: (...args: unknown[]) => mocks.inboxTeamFindFirst(...args), - }, - }, - insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: (...args: unknown[]) => mocks.insertReturning(...args), - }), - }), - }), - delete: () => ({ where: vi.fn() }), - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (col: unknown, val: unknown) => ({ eq: [col, val] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ - contactsToTagsModel: { - contactId: "contactsToTagsModel.contactId", - tagId: "contactsToTagsModel.tagId", - }, metaCapiEventChannelSchema: { safeParse: (value: unknown) => value === "messenger" || value === "instagram" || value === "whatsapp" @@ -99,12 +59,23 @@ vi.mock("@chatbotx.io/business", () => ({ mocks.deleteByCustomFieldId(...args), }, conversationService: { + findLatestCreatedByContact: (...args: unknown[]) => + mocks.findLatestCreatedByContact(...args), updateArchived: (...args: unknown[]) => mocks.updateArchived(...args), updateAssignment: (...args: unknown[]) => mocks.updateAssignment(...args), assignOneOrSkip: (...args: unknown[]) => mocks.assignOneOrSkip(...args), disableBotState: (...args: unknown[]) => mocks.disableBotState(...args), enableBotState: (...args: unknown[]) => mocks.enableBotState(...args), }, + tagService: { + attachExistingToContactForTrigger: (...args: unknown[]) => + mocks.attachExistingToContactForTrigger(...args), + detachFromContactForTrigger: (...args: unknown[]) => + mocks.detachFromContactForTrigger(...args), + }, + flowService: { + findActiveById: (...args: unknown[]) => mocks.findActiveById(...args), + }, tagSyncService: { enqueueAttach: (...args: unknown[]) => mocks.enqueueAttach(...args), enqueueDetach: (...args: unknown[]) => mocks.enqueueDetach(...args), @@ -123,6 +94,16 @@ vi.mock("@chatbotx.io/events/context", () => ({ webhookChannelOrigin: vi.fn(() => "webhook"), })) +// `capi-input-error.ts` (imported transitively for the sendMetaCapiEvent +// branch) pulls `logProviderError` from this separate package subpath, which +// is not covered by the `@chatbotx.io/business` mock above (subpath exports +// are independent module specifiers). Left unmocked, it loads the real +// `@chatbotx.io/database/client` and relations graph against the partial +// schema mock below and crashes at import time. +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), +})) + vi.mock("@chatbotx.io/logger", () => ({ default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, getChildLogger: () => ({ @@ -179,7 +160,7 @@ const MESSENGER_INBOX = { describe("ActionExecutor — per-integration contact inbox attribution", () => { beforeEach(() => { vi.clearAllMocks() - mocks.conversationFindFirst.mockResolvedValue({ + mocks.findLatestCreatedByContact.mockResolvedValue({ id: "conv-1", contactId: "contact-1", workspaceId: "ws-1", @@ -194,7 +175,7 @@ describe("ActionExecutor — per-integration contact inbox attribution", () => { mocks.findByIdForContact.mockResolvedValue(WHATSAPP_INBOX) mocks.findMostRecentByContact.mockResolvedValue(MESSENGER_INBOX) mocks.buildSourceKey.mockReturnValue("source-key") - mocks.flowFindFirst.mockResolvedValue({ + mocks.findActiveById.mockResolvedValue({ id: "flow-1", currentVersionId: "fv-1", }) @@ -328,8 +309,9 @@ describe("ActionExecutor — per-integration contact inbox attribution", () => { mocks.findMostRecentByContact.mockRejectedValue( new Error("resolver should never be called for this branch"), ) - mocks.tagFindMany.mockResolvedValue([{ id: "tag-1" }]) - mocks.insertReturning.mockResolvedValue([{ tagId: "tag-1" }]) + mocks.attachExistingToContactForTrigger.mockResolvedValue([ + { tagId: "tag-1" }, + ]) mocks.assignOneOrSkip.mockResolvedValue(undefined) }) diff --git a/apps/worker/__tests__/condition-evaluator.test.ts b/apps/worker/__tests__/condition-evaluator.test.ts index 1126beaa47..be3199ded5 100644 --- a/apps/worker/__tests__/condition-evaluator.test.ts +++ b/apps/worker/__tests__/condition-evaluator.test.ts @@ -3,20 +3,14 @@ import type { WorkspaceModel } from "@chatbotx.io/database/types" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" import type { ConditionEvaluationContext } from "../src/trigger/types" -const { contactCustomFieldFindFirst, customFieldFindFirst } = vi.hoisted( - () => ({ - contactCustomFieldFindFirst: vi.fn(), - customFieldFindFirst: vi.fn(), - }), -) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactCustomFieldModel: { findFirst: contactCustomFieldFindFirst }, - customFieldModel: { findFirst: customFieldFindFirst }, - }, - }, +const { contactCustomFieldFindValue, customFieldFindBy } = vi.hoisted(() => ({ + contactCustomFieldFindValue: vi.fn(), + customFieldFindBy: vi.fn(), +})) + +vi.mock("@chatbotx.io/business", () => ({ + contactCustomFieldService: { findValue: contactCustomFieldFindValue }, + customFieldService: { findBy: customFieldFindBy }, })) import { ConditionEvaluator } from "../src/trigger/services/condition-evaluator" @@ -110,9 +104,7 @@ describe("ConditionEvaluator dateTimeBasedTrigger timezone", () => { // 14:00 UTC is 21:00 in Asia/Ho_Chi_Minh (+7), so an `at: "21"` condition // only fires when the hour-of-day is resolved in the +7 zone, never in UTC. vi.setSystemTime(new Date("2026-07-11T14:00:00.000Z")) - contactCustomFieldFindFirst.mockResolvedValue({ - value: "2026-07-11T02:00:00.000Z", - }) + contactCustomFieldFindValue.mockResolvedValue("2026-07-11T02:00:00.000Z") }) afterEach(() => { @@ -178,9 +170,7 @@ describe("ConditionEvaluator dateTimeBasedTrigger date-type anchor", () => { // zone. The VN date 2026-07-11 is stored as 2026-07-11T00:00:00+07:00. // The anchor is the START of the day (hour 0), never the legacy end-of-day // (hour 23). - contactCustomFieldFindFirst.mockResolvedValue({ - value: "2026-07-11T00:00:00+07:00", - }) + contactCustomFieldFindValue.mockResolvedValue("2026-07-11T00:00:00+07:00") }) afterEach(() => { @@ -249,7 +239,7 @@ describe("ConditionEvaluator customFieldValueChanged operator vocabulary", () => fieldType: string, newValue: unknown, ): ConditionEvaluationContext => { - customFieldFindFirst.mockResolvedValue({ type: fieldType }) + customFieldFindBy.mockResolvedValue({ type: fieldType }) return buildContext( { type: triggerEventTypes.enum.customFieldValueChanged, diff --git a/apps/worker/__tests__/datetime-trigger-evaluator.test.ts b/apps/worker/__tests__/datetime-trigger-evaluator.test.ts index 1379cc6cd1..9719ee720d 100644 --- a/apps/worker/__tests__/datetime-trigger-evaluator.test.ts +++ b/apps/worker/__tests__/datetime-trigger-evaluator.test.ts @@ -3,34 +3,26 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { actionExecute, - insertTriggerExecution, + listActiveWithConditionsPage, listContactCustomFieldsForDateTimeSweep, listContactCustomFieldsForDateTimeSweepContacts, - triggerExecutionFindMany, - triggerFindMany, + listExecutedPairs, + recordExecution, } = vi.hoisted(() => ({ actionExecute: vi.fn(), - insertTriggerExecution: vi.fn(), + listActiveWithConditionsPage: vi.fn(), listContactCustomFieldsForDateTimeSweep: vi.fn(), listContactCustomFieldsForDateTimeSweepContacts: vi.fn(), - triggerExecutionFindMany: vi.fn(), - triggerFindMany: vi.fn(), + listExecutedPairs: vi.fn(), + recordExecution: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - execute: vi.fn(), - insert: insertTriggerExecution, - query: { - triggerExecutionModel: { - findMany: triggerExecutionFindMany, - }, - triggerModel: { - findMany: triggerFindMany, - }, - }, +vi.mock("@chatbotx.io/business", () => ({ + triggerService: { + listActiveWithConditionsPage, + listExecutedPairs, + recordExecution, }, - sql: vi.fn(), })) vi.mock("@chatbotx.io/database/repositories", () => ({ @@ -112,12 +104,8 @@ describe("evaluateDateTimeTriggers", () => { beforeEach(() => { vi.clearAllMocks() actionExecute.mockResolvedValue(undefined) - triggerExecutionFindMany.mockResolvedValue([]) - insertTriggerExecution.mockReturnValue({ - values: vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn().mockResolvedValue(undefined), - }), - }) + listExecutedPairs.mockResolvedValue([]) + recordExecution.mockResolvedValue(undefined) redis.get.mockResolvedValue(null) redis.set.mockResolvedValue("OK") redis.setex.mockResolvedValue("OK") @@ -137,14 +125,20 @@ describe("evaluateDateTimeTriggers", () => { }), ), ] - triggerFindMany - .mockResolvedValueOnce(firstTriggerChunk) - .mockResolvedValueOnce([ - triggerRow({ - id: "trigger-101", - conditions: [dateTimeCondition("field-2")], - }), - ]) + listActiveWithConditionsPage + .mockResolvedValueOnce({ + triggers: firstTriggerChunk, + nextCursor: "trigger-100", + }) + .mockResolvedValueOnce({ + triggers: [ + triggerRow({ + id: "trigger-101", + conditions: [dateTimeCondition("field-2")], + }), + ], + nextCursor: undefined, + }) listContactCustomFieldsForDateTimeSweep .mockResolvedValueOnce({ rows: [ @@ -220,15 +214,18 @@ describe("evaluateDateTimeTriggers", () => { }) test("waits for all datetime conditions before executing a trigger across cursor pages", async () => { - triggerFindMany.mockResolvedValueOnce([ - triggerRow({ - id: "trigger-001", - conditions: [ - dateTimeCondition("field-1"), - dateTimeCondition("field-2"), - ], - }), - ]) + listActiveWithConditionsPage.mockResolvedValueOnce({ + triggers: [ + triggerRow({ + id: "trigger-001", + conditions: [ + dateTimeCondition("field-1"), + dateTimeCondition("field-2"), + ], + }), + ], + nextCursor: undefined, + }) listContactCustomFieldsForDateTimeSweep .mockResolvedValueOnce({ rows: [ @@ -287,15 +284,18 @@ describe("evaluateDateTimeTriggers", () => { }) test("does not execute a multi-condition trigger when only one datetime condition is present", async () => { - triggerFindMany.mockResolvedValueOnce([ - triggerRow({ - id: "trigger-001", - conditions: [ - dateTimeCondition("field-1"), - dateTimeCondition("field-2"), - ], - }), - ]) + listActiveWithConditionsPage.mockResolvedValueOnce({ + triggers: [ + triggerRow({ + id: "trigger-001", + conditions: [ + dateTimeCondition("field-1"), + dateTimeCondition("field-2"), + ], + }), + ], + nextCursor: undefined, + }) listContactCustomFieldsForDateTimeSweep.mockResolvedValueOnce({ rows: [ contactCustomFieldRow({ @@ -324,18 +324,21 @@ describe("evaluateDateTimeTriggers", () => { // Workspace is UTC, but the condition was saved in Asia/Ho_Chi_Minh (+7). // 14:00 UTC is 21:00 in +7, so `at: "21"` only fires when the condition's // own zone is honored — a UTC resolution would land on hour 14 and miss. - triggerFindMany.mockResolvedValueOnce([ - triggerRow({ - id: "trigger-001", - timezone: "UTC", - conditions: [ - dateTimeCondition("field-1", { - at: "21", - timezone: "Asia/Ho_Chi_Minh", - }), - ], - }), - ]) + listActiveWithConditionsPage.mockResolvedValueOnce({ + triggers: [ + triggerRow({ + id: "trigger-001", + timezone: "UTC", + conditions: [ + dateTimeCondition("field-1", { + at: "21", + timezone: "Asia/Ho_Chi_Minh", + }), + ], + }), + ], + nextCursor: undefined, + }) listContactCustomFieldsForDateTimeSweep.mockResolvedValueOnce({ rows: [ contactCustomFieldRow({ @@ -367,13 +370,16 @@ describe("evaluateDateTimeTriggers", () => { test("falls back to the workspace timezone for legacy conditions with no captured zone", async () => { // The condition predates timezone capture (no zone stored), so day // boundaries and hour-of-day must resolve in the workspace zone (+7). - triggerFindMany.mockResolvedValueOnce([ - triggerRow({ - id: "trigger-001", - timezone: "Asia/Ho_Chi_Minh", - conditions: [dateTimeCondition("field-1", { at: "21" })], - }), - ]) + listActiveWithConditionsPage.mockResolvedValueOnce({ + triggers: [ + triggerRow({ + id: "trigger-001", + timezone: "Asia/Ho_Chi_Minh", + conditions: [dateTimeCondition("field-1", { at: "21" })], + }), + ], + nextCursor: undefined, + }) listContactCustomFieldsForDateTimeSweep.mockResolvedValueOnce({ rows: [ contactCustomFieldRow({ diff --git a/apps/worker/__tests__/enqueue-broadcast.test.ts b/apps/worker/__tests__/enqueue-broadcast.test.ts index b590efe5a1..3157bd3277 100644 --- a/apps/worker/__tests__/enqueue-broadcast.test.ts +++ b/apps/worker/__tests__/enqueue-broadcast.test.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // ── fixed "now" so startOfMinute is deterministic ───────────────────────────── const FIXED_START = new Date("2026-01-01T10:00:00.000Z") -// ── db spy ──────────────────────────────────────────────────────────────────── -const findManyBroadcast = vi.fn() +// ── service spy ─────────────────────────────────────────────────────────────── +const listDueScheduled = vi.fn() // ── queue spy ───────────────────────────────────────────────────────────────── const addBulkSpy = vi.fn() @@ -14,13 +14,9 @@ vi.mock("date-fns", () => ({ startOfMinute: (_input: unknown) => FIXED_START, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findMany: (...args: unknown[]) => findManyBroadcast(...args), - }, - }, +vi.mock("@chatbotx.io/business", () => ({ + broadcastService: { + listDueScheduled: (...args: unknown[]) => listDueScheduled(...args), }, })) @@ -50,7 +46,7 @@ const makeBroadcasts = (count: number) => // ── setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { - findManyBroadcast.mockResolvedValue([]) + listDueScheduled.mockResolvedValue([]) addBulkSpy.mockResolvedValue(undefined) }) @@ -58,7 +54,7 @@ beforeEach(() => { describe("enqueueBroadcast", () => { describe("no scheduled broadcasts found", () => { test("returns { scanned: 0, enqueued: 0 } without calling addBulk", async () => { - findManyBroadcast.mockResolvedValue([]) + listDueScheduled.mockResolvedValue([]) const result = await enqueueBroadcast() @@ -70,7 +66,7 @@ describe("enqueueBroadcast", () => { describe("broadcasts found within one bulk chunk (<= 500)", () => { test("calls addBulk once with prepareBroadcast jobs for each broadcast", async () => { const broadcasts = makeBroadcasts(3) - findManyBroadcast.mockResolvedValue(broadcasts) + listDueScheduled.mockResolvedValue(broadcasts) const result = await enqueueBroadcast() @@ -88,7 +84,7 @@ describe("enqueueBroadcast", () => { }) test("each job has name prepareBroadcast with correct broadcastId and dedup jobId", async () => { - findManyBroadcast.mockResolvedValue(makeBroadcasts(1)) + listDueScheduled.mockResolvedValue(makeBroadcasts(1)) await enqueueBroadcast() @@ -107,7 +103,7 @@ describe("enqueueBroadcast", () => { }) test("clears the dedup jobId on completion and on failure so a terminal job cannot wedge the broadcast forever", async () => { - findManyBroadcast.mockResolvedValue(makeBroadcasts(1)) + listDueScheduled.mockResolvedValue(makeBroadcasts(1)) await enqueueBroadcast() @@ -127,24 +123,20 @@ describe("enqueueBroadcast", () => { expect(jobs[0].opts.removeOnFail).toBe(true) }) - test("queries broadcastModel with status 'scheduled' and schedulesAt lte startTime", async () => { - findManyBroadcast.mockResolvedValue(makeBroadcasts(1)) + test("passes the current startOfMinute as dueAt", async () => { + listDueScheduled.mockResolvedValue(makeBroadcasts(1)) await enqueueBroadcast() - expect(findManyBroadcast).toHaveBeenCalledTimes(1) - const [queryArg] = findManyBroadcast.mock.calls[0] as [ - { where: { status: string; schedulesAt: { lte: Date } } }, - ] - expect(queryArg.where.status).toBe("scheduled") - expect(queryArg.where.schedulesAt.lte).toEqual(FIXED_START) + expect(listDueScheduled).toHaveBeenCalledTimes(1) + expect(listDueScheduled).toHaveBeenCalledWith({ dueAt: FIXED_START }) }) }) describe("more than 500 broadcasts (multi-chunk batching)", () => { test("splits into chunks of 500 and calls addBulk once per chunk", async () => { const broadcasts = makeBroadcasts(501) - findManyBroadcast.mockResolvedValue(broadcasts) + listDueScheduled.mockResolvedValue(broadcasts) const result = await enqueueBroadcast() @@ -158,7 +150,7 @@ describe("enqueueBroadcast", () => { }) test("exactly 1000 broadcasts → two equal batches of 500", async () => { - findManyBroadcast.mockResolvedValue(makeBroadcasts(1000)) + listDueScheduled.mockResolvedValue(makeBroadcasts(1000)) const result = await enqueueBroadcast() diff --git a/apps/worker/__tests__/export-contacts-csv.test.ts b/apps/worker/__tests__/export-contacts-csv.test.ts index 2a8c4be266..58fea83a08 100644 --- a/apps/worker/__tests__/export-contacts-csv.test.ts +++ b/apps/worker/__tests__/export-contacts-csv.test.ts @@ -21,6 +21,18 @@ vi.mock("@chatbotx.io/database/partials", async () => vi.importActual("@chatbotx.io/database/partials"), ) +// Prevent the repositories barrel from being evaluated — it reaches +// contact-inbox/repository.ts, which needs a fuller @chatbotx.io/database/schema +// mock than this file provides. +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactRepository: { + listForExportPage: vi.fn(), + }, + fileRepository: { + updateForWorkspace: vi.fn(), + }, +})) + vi.mock("@chatbotx.io/database/queries", () => ({ applyContactFilter: (criteria: unknown) => ({ __filter: criteria }), pruneEmailPhoneFilterConditions: (criteria: unknown) => criteria, diff --git a/apps/worker/__tests__/export-contacts-fields.test.ts b/apps/worker/__tests__/export-contacts-fields.test.ts index 198a892971..63ed279988 100644 --- a/apps/worker/__tests__/export-contacts-fields.test.ts +++ b/apps/worker/__tests__/export-contacts-fields.test.ts @@ -8,29 +8,16 @@ const findManyCustomFields = vi.fn() const updateSet = vi.fn() const updateWhere = vi.fn() -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactModel: { - findMany: (...args: unknown[]) => findManyContacts(...args), - }, - tagModel: { - findMany: (...args: unknown[]) => findManyTags(...args), - }, - customFieldModel: { - findMany: (...args: unknown[]) => findManyCustomFields(...args), - }, +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactRepository: { + listForExportPage: (...args: unknown[]) => findManyContacts(...args), + }, + fileRepository: { + updateForWorkspace: (input: { values: unknown }) => { + updateSet(input.values) + return updateWhere(input) }, - update: () => ({ - set: (values: unknown) => { - updateSet(values) - return { where: (cond: unknown) => updateWhere(cond) } - }, - }), }, - and: (...args: unknown[]) => ({ and: args }), - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - isNull: (column: unknown) => ({ isNull: column }), })) vi.mock("@chatbotx.io/database/partials", async () => @@ -44,11 +31,12 @@ vi.mock("@chatbotx.io/database/queries", () => ({ vi.mock("@chatbotx.io/business", () => ({ workspaceService: { find: vi.fn(async () => ({ timezone: "UTC" })) }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - contactCustomFieldModel: {}, - fileModel: { id: "File.id", workspaceId: "File.workspaceId" }, + tagService: { + findManyByIds: (...args: unknown[]) => findManyTags(...args), + }, + customFieldService: { + findManyByIds: (...args: unknown[]) => findManyCustomFields(...args), + }, })) vi.mock("@chatbotx.io/worker-config", () => ({ @@ -191,7 +179,7 @@ describe("buildSelectedFields", () => { expect(result).toEqual([{ type: "tag", value: "t1", header: "VIP" }]) }) - test("queries tagModel.findMany with the correct workspaceId in the where clause", async () => { + test("scopes the tagService lookup to the workspace and the requested tag ids", async () => { // Arrange findManyTags.mockResolvedValueOnce([{ id: "t1", name: "VIP" }]) @@ -201,10 +189,11 @@ describe("buildSelectedFields", () => { // Assert expect(findManyTags).toHaveBeenCalledOnce() const callArg = findManyTags.mock.calls[0][0] as { - where: { id: { in: string[] }; workspaceId: string } + workspaceId: string + ids: string[] } - expect(callArg.where.workspaceId).toBe(WORKSPACE_ID) - expect(callArg.where.id.in).toContain("t1") + expect(callArg.workspaceId).toBe(WORKSPACE_ID) + expect(callArg.ids).toContain("t1") }) test("falls back to the raw tag id when no matching row is returned", async () => { @@ -219,7 +208,7 @@ describe("buildSelectedFields", () => { ]) }) - test("does NOT call tagModel.findMany when there are no tag fields", async () => { + test("does NOT call tagService.findManyByIds when there are no tag fields", async () => { // Arrange const fields = ["sys:email"] @@ -250,7 +239,7 @@ describe("buildSelectedFields", () => { ]) }) - test("queries customFieldModel.findMany with the correct workspaceId", async () => { + test("scopes the customFieldService lookup to the workspace and the requested ids", async () => { // Arrange findManyCustomFields.mockResolvedValueOnce([{ id: "c1", name: "Plan" }]) @@ -260,10 +249,11 @@ describe("buildSelectedFields", () => { // Assert expect(findManyCustomFields).toHaveBeenCalledOnce() const callArg = findManyCustomFields.mock.calls[0][0] as { - where: { id: { in: string[] }; workspaceId: string } + workspaceId: string + ids: string[] } - expect(callArg.where.workspaceId).toBe(WORKSPACE_ID) - expect(callArg.where.id.in).toContain("c1") + expect(callArg.workspaceId).toBe(WORKSPACE_ID) + expect(callArg.ids).toContain("c1") }) test("falls back to the raw custom field id when no matching row is returned", async () => { diff --git a/apps/worker/__tests__/export-contacts-handler.test.ts b/apps/worker/__tests__/export-contacts-handler.test.ts index 8828d08517..a25c13b892 100644 --- a/apps/worker/__tests__/export-contacts-handler.test.ts +++ b/apps/worker/__tests__/export-contacts-handler.test.ts @@ -10,34 +10,16 @@ const findFirstWorkspace = vi.fn() const updateSet = vi.fn() const updateWhere = vi.fn() -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactModel: { - findMany: (...args: unknown[]) => findManyContacts(...args), - }, - tagModel: { - findMany: (...args: unknown[]) => findManyTags(...args), - }, - customFieldModel: { - findMany: (...args: unknown[]) => findManyCustomFields(...args), - }, - // The handler reads the workspace timezone to format date/datetime custom - // fields for CSV export. - workspaceModel: { - findFirst: (...args: unknown[]) => findFirstWorkspace(...args), - }, +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactRepository: { + listForExportPage: (...args: unknown[]) => findManyContacts(...args), + }, + fileRepository: { + updateForWorkspace: (input: { values: unknown }) => { + updateSet(input.values) + return updateWhere(input) }, - update: () => ({ - set: (values: unknown) => { - updateSet(values) - return { where: (cond: unknown) => updateWhere(cond) } - }, - }), }, - and: (...args: unknown[]) => ({ and: args }), - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - isNull: (column: unknown) => ({ isNull: column }), })) vi.mock("@chatbotx.io/database/partials", async () => @@ -50,7 +32,17 @@ vi.mock("@chatbotx.io/database/queries", () => ({ })) vi.mock("@chatbotx.io/business", () => ({ - workspaceService: { find: vi.fn(async () => ({ timezone: "UTC" })) }, + // The handler reads the workspace timezone to format date/datetime custom + // fields for CSV export. + workspaceService: { + find: (...args: unknown[]) => findFirstWorkspace(...args), + }, + tagService: { + findManyByIds: (...args: unknown[]) => findManyTags(...args), + }, + customFieldService: { + findManyByIds: (...args: unknown[]) => findManyCustomFields(...args), + }, })) const recordAuditLog = vi.fn() @@ -66,11 +58,6 @@ vi.mock("@chatbotx.io/business/audit", async () => { } }) -vi.mock("@chatbotx.io/database/schema", () => ({ - contactCustomFieldModel: {}, - fileModel: { id: "File.id", workspaceId: "File.workspaceId" }, -})) - // Small page size keeps multi-page pagination tests to a few rows. vi.mock("@chatbotx.io/worker-config", () => ({ loopableItemsCount: 2, @@ -324,20 +311,15 @@ describe("loopableExportContacts", () => { await loopableExportContacts(buildData({ fields: ["sys:sourceUserId"] })) + // The `with.contactInboxes` shape now lives inside + // contactRepository.listForExportPage (pinned in + // packages/database/__tests__/contact-export-page-repository.test.ts); the + // handler's job is to signal that the WhatsApp User ID column is selected, + // which is what lifts the earliest-row limit. const query = findManyContacts.mock.calls[0][0] as { - with: { - contactInboxes: { - columns: Record - limit?: number - } - } + includeSourceUserId: boolean } - expect(query.with.contactInboxes.columns).toMatchObject({ - sourceId: true, - sourceUserId: true, - }) - // Multi-inbox scan is only paid for when the column is actually selected. - expect(query.with.contactInboxes.limit).toBeUndefined() + expect(query.includeSourceUserId).toBe(true) }) test("keeps the single-row contactInboxes load when the WhatsApp User ID column is NOT selected (regression)", async () => { @@ -346,10 +328,12 @@ describe("loopableExportContacts", () => { await loopableExportContacts(buildData()) + // Regression: an ordinary export must NOT ask for the multi-inbox scan, + // so the repository keeps its single-row contactInboxes load. const query = findManyContacts.mock.calls[0][0] as { - with: { contactInboxes: { limit?: number } } + includeSourceUserId: boolean } - expect(query.with.contactInboxes.limit).toBe(1) + expect(query.includeSourceUserId).toBe(false) }) test("filters by contactIds when no filter is supplied", async () => { diff --git a/apps/worker/__tests__/export-contacts-where.test.ts b/apps/worker/__tests__/export-contacts-where.test.ts index ec1e3db7b5..b9d09d5b69 100644 --- a/apps/worker/__tests__/export-contacts-where.test.ts +++ b/apps/worker/__tests__/export-contacts-where.test.ts @@ -23,6 +23,18 @@ vi.mock("@chatbotx.io/database/partials", async () => vi.importActual("@chatbotx.io/database/partials"), ) +// Prevent the repositories barrel from being evaluated — it reaches +// contact-inbox/repository.ts, which needs a fuller @chatbotx.io/database/schema +// mock than this file provides. +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactRepository: { + listForExportPage: vi.fn(), + }, + fileRepository: { + updateForWorkspace: vi.fn(), + }, +})) + const applyContactFilterSpy = vi.fn((criteria: unknown) => ({ __filter: criteria, })) diff --git a/apps/worker/__tests__/import-contacts-handler.test.ts b/apps/worker/__tests__/import-contacts-handler.test.ts index f21b9eb9e6..8b2ac59bdf 100644 --- a/apps/worker/__tests__/import-contacts-handler.test.ts +++ b/apps/worker/__tests__/import-contacts-handler.test.ts @@ -20,84 +20,68 @@ const deleteWhere = vi.fn() // per-test updates. const conflict = { drop: 0 } -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - inboxModel: { - findFirst: (...args: unknown[]) => findFirstInbox(...args), - }, - tagModel: { - findFirst: (...args: unknown[]) => findFirstTag(...args), - }, - customFieldModel: { - findMany: (...args: unknown[]) => findManyCustomFields(...args), - }, - contactInboxModel: { - findMany: (...args: unknown[]) => findManyContactInbox(...args), - }, - }, - update: () => ({ - set: (values: unknown) => { - updateSet(values) - return { where: (cond: unknown) => updateWhere(cond) } - }, - }), - transaction: (cb: (tx: unknown) => unknown) => { - transactionFn() - return cb({ - insert: () => ({ - values: (v: unknown) => { - insertValues(v) - const rows = Array.isArray(v) - ? (v as Array<{ contactId?: string; sourceId?: string }>) - : [v as { contactId?: string; sourceId?: string }] - return { - onConflictDoNothing: () => ({ - // Echo back the contactId of each inserted row so the handler can - // compute which contacts survived. ContactInbox rows carry a - // `sourceId`; drop `inboxConflictDrop` of them to simulate a - // concurrent-insert conflict. - returning: () => { - const isContactInbox = rows.some( - (r) => r && typeof r === "object" && "sourceId" in r, - ) - const surviving = - isContactInbox && conflict.drop > 0 - ? rows.slice(0, Math.max(0, rows.length - conflict.drop)) - : rows - return surviving.map((item) => ({ - contactId: item.contactId, - })) - }, - }), - } - }, - }), - delete: () => ({ - where: (cond: unknown) => deleteWhere(cond), - }), - }) - }, +// The contact-insert transaction now lives in +// `contactService.insertImportedContactBatch` (packages/business). This fake +// reproduces its observable behaviour at the new boundary so every assertion +// below still holds: `insertBatch` stands in for the single bulk transaction, +// `insertValues` records the ContactInbox rows it was handed, `deleteWhere` +// records the orphan prune, and `conflict.drop` still simulates a late +// ON CONFLICT DO NOTHING race. The transaction internals themselves are +// covered by packages/business/__tests__/contact-insert-imported-batch.test.ts. +const insertImportedContactBatch = vi.fn( + (input: { + workspaceId: string + inbox: { id: string; channel: string } + accepted: Array<{ + contactId: string + contactInboxId: string + row: { + externalId?: string | null + sourceUserId?: string | null + customFields: Array<{ customFieldId: string; value: string }> + } + }> + tagId?: string + }) => { + transactionFn() + insertValues( + input.accepted.map(({ contactId, contactInboxId, row }) => ({ + id: contactInboxId, + contactId, + inboxId: input.inbox.id, + channel: input.inbox.channel, + sourceId: row.externalId, + sourceUserId: row.sourceUserId ?? null, + })), + ) + const survivors = + conflict.drop > 0 + ? input.accepted.slice( + 0, + Math.max(0, input.accepted.length - conflict.drop), + ) + : input.accepted + const orphanCount = input.accepted.length - survivors.length + if (orphanCount > 0) { + deleteWhere( + input.accepted + .slice(survivors.length) + .map(({ contactId }) => contactId), + ) + } + if (survivors.length === 0) { + return Promise.resolve({ inserted: 0, orphanCount }) + } + insertNormalizedCustomFieldValues({ + workspaceId: input.workspaceId, + entries: survivors.map(({ contactId, row }) => ({ + contactId, + fields: row.customFields, + })), + }) + return Promise.resolve({ inserted: survivors.length, orphanCount }) }, - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - inArray: (a: unknown, b: unknown) => ({ inArray: [a, b] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - workspaceUsageModel: {}, - userQuotaModel: {}, - questionnaireSubmissionModel: {}, - adsConversionEventModel: {}, - refLinkStatModel: {}, - contactsOnSequenceModel: {}, - contactsOnBroadcastsModel: {}, - contactCustomFieldModel: {}, - contactInboxModel: {}, - contactModel: {}, - contactsToTagsModel: {}, - conversationModel: {}, - importModel: { id: "Import.id" }, -})) +) const workspaceFind = vi.fn() // Returns the sourceId/sourceUserId identities already linked to the inbox. @@ -174,6 +158,19 @@ vi.mock("@chatbotx.io/business", () => ({ workspaceService: { find: (...args: unknown[]) => workspaceFind(...args), }, + inboxService: { + find: (...args: unknown[]) => findFirstInbox(...args), + }, + tagService: { + findById: (...args: unknown[]) => findFirstTag(...args), + }, + customFieldService: { + findManyByIds: (...args: unknown[]) => findManyCustomFields(...args), + }, + contactService: { + insertImportedContactBatch: (...args: unknown[]) => + (insertImportedContactBatch as (...a: unknown[]) => unknown)(...args), + }, contactInboxService: { findExistingSourceIdentities: (...args: unknown[]) => findExistingSourceIdentities(...args), @@ -314,6 +311,7 @@ beforeEach(() => { transactionFn.mockReset() deleteWhere.mockReset() conflict.drop = 0 + insertImportedContactBatch.mockClear() getObjectStream.mockReset() headObject.mockReset() // Default: small file, passes the size check. @@ -443,11 +441,10 @@ describe("contacts import pipeline", () => { expect(lastUpdate()).toMatchObject({ status: "completed" }) // The custom-field lookup only sees the real custom field id. - expect(findManyCustomFields).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ id: { in: ["7"] } }), - }), - ) + expect(findManyCustomFields).toHaveBeenCalledWith({ + workspaceId: "ws-1", + ids: ["7"], + }) expect(botFieldUpdateByKey).toHaveBeenCalledTimes(1) expect(botFieldUpdateByKey).toHaveBeenCalledWith({ workspaceId: "ws-1", diff --git a/apps/worker/__tests__/prepare-broadcast.test.ts b/apps/worker/__tests__/prepare-broadcast.test.ts index 1355769858..56af6d7cfe 100644 --- a/apps/worker/__tests__/prepare-broadcast.test.ts +++ b/apps/worker/__tests__/prepare-broadcast.test.ts @@ -1,31 +1,21 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const findFirstBroadcast = vi.fn() -const findFirstMessengerTemplate = vi.fn() +const findScheduledForPrepare = vi.fn() +const resolveTemplateIntegrationMessengerId = vi.fn() const findDMByContactIds = vi.fn() const forEachAudienceChunk = vi.fn() +const insertRecipients = vi.fn() +const promoteAfterPrepare = vi.fn() const scheduleAddSpy = vi.fn() const loggerInfoSpy = vi.fn() const loggerWarnSpy = vi.fn() const purgeBroadcastRecipientsSpy = vi.fn() const blockedWorkspaceIds = new Set() -type UpdateCall = { - table: unknown - values: Record - condition: unknown -} -const updateCalls: UpdateCall[] = [] - -type InsertCall = { table: unknown; values: unknown } -const insertCalls: InsertCall[] = [] - -const onConflictSpy = vi.fn() - -// Rows returned by the promotion UPDATE's `.returning()`. Defaults to a -// match (promotion succeeded) so existing enqueue-path tests keep passing; +// Whether the promotion CAS should report success. Defaults to true +// (promotion succeeded) so existing enqueue-path tests keep passing; // individual tests override this to simulate a lost promotion race. -let promotionReturningRows: Array<{ id: string }> = [{ id: "broadcast-1" }] +let promotionSucceeds = true vi.mock("@chatbotx.io/business", () => ({ withBlockedOwnerGuard: async ( @@ -34,6 +24,12 @@ vi.mock("@chatbotx.io/business", () => ({ ) => (blockedWorkspaceIds.has(String(workspaceId)) ? undefined : fn()), broadcastService: { forEachAudienceChunk: (...args: unknown[]) => forEachAudienceChunk(...args), + findScheduledForPrepare: (...args: unknown[]) => + findScheduledForPrepare(...args), + resolveTemplateIntegrationMessengerId: (...args: unknown[]) => + resolveTemplateIntegrationMessengerId(...args), + insertRecipients: (...args: unknown[]) => insertRecipients(...args), + promoteAfterPrepare: (...args: unknown[]) => promoteAfterPrepare(...args), }, conversationService: { findDMByContactIds: (...args: unknown[]) => findDMByContactIds(...args), @@ -44,59 +40,11 @@ vi.mock("@chatbotx.io/database/partials", async () => vi.importActual("@chatbotx.io/database/partials"), ) -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { - id: "Broadcast.id", - status: "Broadcast.status", - deletedAt: "Broadcast.deletedAt", - resumeCount: "Broadcast.resumeCount", - __name: "broadcastModel", - }, - contactsOnBroadcastsModel: { __name: "contactsOnBroadcastsModel" }, -})) - vi.mock("@chatbotx.io/database/repositories", () => ({ purgeBroadcastRecipients: (...args: unknown[]) => purgeBroadcastRecipientsSpy(...args), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findFirst: (...args: unknown[]) => findFirstBroadcast(...args), - }, - messengerMessageTemplateModel: { - findFirst: (...args: unknown[]) => findFirstMessengerTemplate(...args), - }, - }, - update: (table: unknown) => ({ - set: (values: Record) => ({ - where: (condition: unknown) => { - updateCalls.push({ table, values, condition }) - return { - returning: () => Promise.resolve(promotionReturningRows), - } - }, - }), - }), - insert: (table: unknown) => ({ - values: (vals: unknown) => { - insertCalls.push({ table, values: vals }) - return { - onConflictDoNothing: () => { - onConflictSpy() - return Promise.resolve() - }, - } - }, - }), - }, - eq: (left: unknown, right: unknown) => ({ __eq: [left, right] }), - and: (...args: unknown[]) => ({ __and: args }), - isNull: (value: unknown) => ({ __isNull: value }), -})) - vi.mock("../src/lib/logger", () => ({ logger: { info: (...args: unknown[]) => loggerInfoSpy(...args), @@ -140,45 +88,46 @@ const baseBroadcast = () => ({ }) beforeEach(() => { - updateCalls.length = 0 - insertCalls.length = 0 - findFirstBroadcast.mockResolvedValue(undefined) - findFirstMessengerTemplate.mockResolvedValue(undefined) + findScheduledForPrepare.mockResolvedValue(undefined) + resolveTemplateIntegrationMessengerId.mockResolvedValue(null) findDMByContactIds.mockResolvedValue([]) forEachAudienceChunk.mockResolvedValue(undefined) + insertRecipients.mockResolvedValue(undefined) + promoteAfterPrepare.mockImplementation(() => + Promise.resolve(promotionSucceeds), + ) scheduleAddSpy.mockReset() loggerInfoSpy.mockReset() loggerWarnSpy.mockReset() - onConflictSpy.mockReset() purgeBroadcastRecipientsSpy.mockReset() purgeBroadcastRecipientsSpy.mockResolvedValue({ deleted: 0, stopReason: "drained", }) - promotionReturningRows = [{ id: BROADCAST_ID }] + promotionSucceeds = true blockedWorkspaceIds.clear() }) describe("prepareBroadcast", () => { test("returns without db writes or queue enqueues when the broadcast is missing", async () => { - findFirstBroadcast.mockResolvedValue(undefined) + findScheduledForPrepare.mockResolvedValue(undefined) await prepareBroadcast(BROADCAST_ID) - expect(updateCalls).toHaveLength(0) - expect(insertCalls).toHaveLength(0) + expect(insertRecipients).not.toHaveBeenCalled() + expect(promoteAfterPrepare).not.toHaveBeenCalled() expect(forEachAudienceChunk).not.toHaveBeenCalled() expect(scheduleAddSpy).not.toHaveBeenCalled() }) test("returns without db writes or queue enqueues when the workspace is frozen", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) blockedWorkspaceIds.add(WORKSPACE_ID) await prepareBroadcast(BROADCAST_ID) - expect(updateCalls).toHaveLength(0) - expect(insertCalls).toHaveLength(0) + expect(insertRecipients).not.toHaveBeenCalled() + expect(promoteAfterPrepare).not.toHaveBeenCalled() expect(forEachAudienceChunk).not.toHaveBeenCalled() expect(scheduleAddSpy).not.toHaveBeenCalled() }) @@ -188,7 +137,7 @@ describe("prepareBroadcast", () => { operator: "and", conditions: [{ field: "fullName", operator: "contains", value: "Ada" }], } - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "whatsapp", integrationWhatsappId: "wa-int-1", @@ -213,7 +162,7 @@ describe("prepareBroadcast", () => { }) test("loads the target rows and forwards their inbox ids for a multi-page broadcast", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "whatsapp", subaction: "whatsappTemplateMessage", @@ -223,11 +172,9 @@ describe("prepareBroadcast", () => { await prepareBroadcast(BROADCAST_ID) - expect(findFirstBroadcast).toHaveBeenCalledWith( - expect.objectContaining({ - with: { targets: { columns: { inboxId: true } } }, - }), - ) + expect(findScheduledForPrepare).toHaveBeenCalledWith({ + broadcastId: BROADCAST_ID, + }) expect(forEachAudienceChunk).toHaveBeenCalledWith( expect.objectContaining({ channels: ["whatsapp"], @@ -240,24 +187,19 @@ describe("prepareBroadcast", () => { }) test("derives Messenger template integration id and forwards it to the audience input", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "messenger", subaction: "messengerTemplateMessage", templateId: "template-1", }) - findFirstMessengerTemplate.mockResolvedValue({ - integrationMessengerId: "messenger-int-1", - }) + resolveTemplateIntegrationMessengerId.mockResolvedValue("messenger-int-1") await prepareBroadcast(BROADCAST_ID) - expect(findFirstMessengerTemplate).toHaveBeenCalledWith({ - where: { - id: "template-1", - integrationMessenger: { workspaceId: WORKSPACE_ID }, - }, - columns: { integrationMessengerId: true }, + expect(resolveTemplateIntegrationMessengerId).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + templateId: "template-1", }) expect(forEachAudienceChunk).toHaveBeenCalledWith( expect.objectContaining({ @@ -271,7 +213,7 @@ describe("prepareBroadcast", () => { }) test("forwards the persisted Messenger integration id for flow broadcasts without a template", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "messenger", subaction: "messengerTemplateMessage", @@ -281,7 +223,7 @@ describe("prepareBroadcast", () => { await prepareBroadcast(BROADCAST_ID) - expect(findFirstMessengerTemplate).not.toHaveBeenCalled() + expect(resolveTemplateIntegrationMessengerId).not.toHaveBeenCalled() expect(forEachAudienceChunk).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: WORKSPACE_ID, @@ -293,7 +235,7 @@ describe("prepareBroadcast", () => { }) test("prefers the persisted Messenger integration id over template derivation", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "messenger", subaction: "messengerTemplateMessage", @@ -303,7 +245,7 @@ describe("prepareBroadcast", () => { await prepareBroadcast(BROADCAST_ID) - expect(findFirstMessengerTemplate).not.toHaveBeenCalled() + expect(resolveTemplateIntegrationMessengerId).not.toHaveBeenCalled() expect(forEachAudienceChunk).toHaveBeenCalledWith( expect.objectContaining({ integrationMessengerId: "messenger-int-1", @@ -313,7 +255,7 @@ describe("prepareBroadcast", () => { }) test("scopes a targets-mode broadcast whose pages were all deleted to nobody, never the whole channel", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "whatsapp", subaction: "whatsappTemplateMessage", @@ -330,7 +272,7 @@ describe("prepareBroadcast", () => { }) test("fails closed for invalid persisted channel and subaction values", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "bad-channel", subaction: "bad-subaction", @@ -345,14 +287,13 @@ describe("prepareBroadcast", () => { }), expect.any(Function), ) - expect(updateCalls[0].values).toMatchObject({ - status: "sent", - contactCount: 0, - }) + expect(promoteAfterPrepare).toHaveBeenCalledWith( + expect.objectContaining({ status: "sent", contactCount: 0 }), + ) }) test("inserts recipients with DM conversations, skips missing conversations, and enqueues sendBroadcast", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) findDMByContactIds.mockResolvedValue([ { id: "conv-1", contactId: "contact-1" }, ]) @@ -377,25 +318,25 @@ describe("prepareBroadcast", () => { contactIds: ["contact-1", "contact-2"], channel: "messenger", }) - expect(insertCalls).toHaveLength(1) - expect(onConflictSpy).toHaveBeenCalledTimes(1) - expect(insertCalls[0].values).toEqual([ - { - broadcastId: BROADCAST_ID, - contactId: "contact-1", - contactInboxId: "ci-1", - conversationId: "conv-1", - }, - ]) + expect(insertRecipients).toHaveBeenCalledTimes(1) + expect(insertRecipients).toHaveBeenCalledWith({ + recipients: [ + { + broadcastId: BROADCAST_ID, + contactId: "contact-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }, + ], + }) expect(loggerInfoSpy).toHaveBeenCalledWith( { broadcastId: BROADCAST_ID, skippedCount: 1 }, "Skipped broadcast contacts without a DM conversation", ) - expect(updateCalls).toHaveLength(1) - expect(updateCalls[0].values).toMatchObject({ - status: "sending", - contactCount: 1, - }) + expect(promoteAfterPrepare).toHaveBeenCalledTimes(1) + expect(promoteAfterPrepare).toHaveBeenCalledWith( + expect.objectContaining({ status: "sending", contactCount: 1 }), + ) expect(scheduleAddSpy).toHaveBeenCalledWith( "sendBroadcast", expect.objectContaining({ @@ -412,7 +353,7 @@ describe("prepareBroadcast", () => { }) test("passes the broadcast channel to the DM conversation lookup so TikTok resolves by sourceId", async () => { - findFirstBroadcast.mockResolvedValue({ + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), channel: "tiktok", }) @@ -440,7 +381,7 @@ describe("prepareBroadcast", () => { }) test("does not insert or enqueue when all audience contacts lack a DM conversation", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) findDMByContactIds.mockResolvedValue([]) forEachAudienceChunk.mockImplementation( async ( @@ -455,12 +396,11 @@ describe("prepareBroadcast", () => { await prepareBroadcast(BROADCAST_ID) - expect(insertCalls).toHaveLength(0) - expect(updateCalls).toHaveLength(1) - expect(updateCalls[0].values).toMatchObject({ - status: "sent", - contactCount: 0, - }) + expect(insertRecipients).not.toHaveBeenCalled() + expect(promoteAfterPrepare).toHaveBeenCalledTimes(1) + expect(promoteAfterPrepare).toHaveBeenCalledWith( + expect.objectContaining({ status: "sent", contactCount: 0 }), + ) expect(scheduleAddSpy).not.toHaveBeenCalled() expect(loggerInfoSpy).toHaveBeenCalledWith( { broadcastId: BROADCAST_ID, skippedCount: 1 }, @@ -469,23 +409,22 @@ describe("prepareBroadcast", () => { }) test("marks sent with contactCount zero and does not enqueue when the audience is empty", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) forEachAudienceChunk.mockResolvedValue(undefined) await prepareBroadcast(BROADCAST_ID) - expect(insertCalls).toHaveLength(0) - expect(updateCalls).toHaveLength(1) - expect(updateCalls[0].values).toMatchObject({ - status: "sent", - contactCount: 0, - }) + expect(insertRecipients).not.toHaveBeenCalled() + expect(promoteAfterPrepare).toHaveBeenCalledTimes(1) + expect(promoteAfterPrepare).toHaveBeenCalledWith( + expect.objectContaining({ status: "sent", contactCount: 0 }), + ) expect(scheduleAddSpy).not.toHaveBeenCalled() }) describe("stale recipient cleanup", () => { test("purges any existing ContactOnBroadcast rows before rebuilding the audience", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) forEachAudienceChunk.mockResolvedValue(undefined) await prepareBroadcast(BROADCAST_ID) @@ -501,11 +440,11 @@ describe("prepareBroadcast", () => { }) test("does not purge when the broadcast is missing or the workspace is blocked", async () => { - findFirstBroadcast.mockResolvedValue(undefined) + findScheduledForPrepare.mockResolvedValue(undefined) await prepareBroadcast(BROADCAST_ID) expect(purgeBroadcastRecipientsSpy).not.toHaveBeenCalled() - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) blockedWorkspaceIds.add(WORKSPACE_ID) await prepareBroadcast(BROADCAST_ID) expect(purgeBroadcastRecipientsSpy).not.toHaveBeenCalled() @@ -513,8 +452,8 @@ describe("prepareBroadcast", () => { }) describe("promotion-epoch pin", () => { - test("pins the promotion UPDATE to id, status, deletedAt, and the resumeCount read at the start of the run", async () => { - findFirstBroadcast.mockResolvedValue({ + test("passes the broadcastId, computed status/contactCount, and the resumeCount read at the start of the run", async () => { + findScheduledForPrepare.mockResolvedValue({ ...baseBroadcast(), resumeCount: 3, }) @@ -522,18 +461,16 @@ describe("prepareBroadcast", () => { await prepareBroadcast(BROADCAST_ID) - expect(updateCalls[0].condition).toEqual({ - __and: [ - { __eq: ["Broadcast.id", BROADCAST_ID] }, - { __eq: ["Broadcast.status", "scheduled"] }, - { __isNull: "Broadcast.deletedAt" }, - { __eq: ["Broadcast.resumeCount", 3] }, - ], + expect(promoteAfterPrepare).toHaveBeenCalledWith({ + broadcastId: BROADCAST_ID, + status: "sent", + contactCount: 0, + promotionEpoch: 3, }) }) - test("skips the sendBroadcast enqueue when the promotion UPDATE matches 0 rows (lost the race)", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + test("skips the sendBroadcast enqueue when promoteAfterPrepare reports the CAS lost (the race)", async () => { + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) findDMByContactIds.mockResolvedValue([ { id: "conv-1", contactId: "contact-1" }, ]) @@ -547,7 +484,7 @@ describe("prepareBroadcast", () => { await onChunk([{ id: "ci-1", contactId: "contact-1" }]) }, ) - promotionReturningRows = [] + promotionSucceeds = false await prepareBroadcast(BROADCAST_ID) @@ -558,8 +495,8 @@ describe("prepareBroadcast", () => { ) }) - test("still enqueues sendBroadcast when the promotion UPDATE matches", async () => { - findFirstBroadcast.mockResolvedValue(baseBroadcast()) + test("still enqueues sendBroadcast when promoteAfterPrepare reports the CAS won", async () => { + findScheduledForPrepare.mockResolvedValue(baseBroadcast()) findDMByContactIds.mockResolvedValue([ { id: "conv-1", contactId: "contact-1" }, ]) @@ -573,7 +510,7 @@ describe("prepareBroadcast", () => { await onChunk([{ id: "ci-1", contactId: "contact-1" }]) }, ) - promotionReturningRows = [{ id: BROADCAST_ID }] + promotionSucceeds = true await prepareBroadcast(BROADCAST_ID) diff --git a/apps/worker/__tests__/process-broadcast-contacts.test.ts b/apps/worker/__tests__/process-broadcast-contacts.test.ts index c0bf5e67cc..2d6ccca7bf 100644 --- a/apps/worker/__tests__/process-broadcast-contacts.test.ts +++ b/apps/worker/__tests__/process-broadcast-contacts.test.ts @@ -1,16 +1,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ── db spies ────────────────────────────────────────────────────────────────── -const findManyBroadcast = vi.fn() -const findManyContactsOnBroadcasts = vi.fn() -const updateWhereSpy = vi.fn() - -type UpdateCall = { - table: unknown - values: Record - condition: unknown +// ── service spies (replace direct db.* calls in the handler) ───────────────── +const listSendableById = vi.fn() +const listPendingRecipients = vi.fn() +const markContactFailedSpy = vi.fn() + +type MarkContactFailedCall = { + broadcastId: string + contactId: string + reason: string } -const updateCalls: UpdateCall[] = [] +const markContactFailedCalls: MarkContactFailedCall[] = [] // ── queue spies ─────────────────────────────────────────────────────────────── const chatAddSpy = vi.fn() @@ -21,56 +21,35 @@ const scheduleAddSpy = vi.fn() const loggerErrorSpy = vi.fn() // ── business service spies ─────────────────────────────────────────────────── +const blockedOwnerGuard = vi.fn() +// Mutable so the hoisted mock factory closure observes per-test updates. +const blockedOwnerGuardBlocked = { blocked: false } const markHandoffCompleted = vi.fn() const markContactSentIfSending = vi.fn() // ── mocks ───────────────────────────────────────────────────────────────────── vi.mock("@chatbotx.io/business", () => ({ withBlockedOwnerGuard: async ( - _workspaceId: unknown, + workspaceId: unknown, fn: () => Promise, - ) => fn(), + ) => { + blockedOwnerGuard(workspaceId) + if (blockedOwnerGuardBlocked.blocked) { + return + } + return await fn() + }, broadcastService: { markHandoffCompleted: (...args: unknown[]) => markHandoffCompleted(...args), markContactSentIfSending: (...args: unknown[]) => markContactSentIfSending(...args), - }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findMany: (...args: unknown[]) => findManyBroadcast(...args), - }, - contactsOnBroadcastsModel: { - findMany: (...args: unknown[]) => findManyContactsOnBroadcasts(...args), - }, + listSendableById: (...args: unknown[]) => listSendableById(...args), + listPendingRecipients: (...args: unknown[]) => + listPendingRecipients(...args), + markContactFailed: (input: MarkContactFailedCall) => { + markContactFailedCalls.push(input) + return markContactFailedSpy(input) }, - update: (table: unknown) => ({ - set: (values: Record) => ({ - where: (condition: unknown) => { - updateCalls.push({ table, values, condition }) - return updateWhereSpy() - }, - }), - }), - }, - and: (...args: unknown[]) => ({ __and: args }), - eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ - __sql: { strings: [...strings], values }, - }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { id: "broadcast.id", __name: "broadcastModel" }, - contactsOnBroadcastsModel: { - broadcastId: "cob.broadcastId", - contactId: "cob.contactId", - failedAt: "cob.failedAt", - errorContent: "cob.errorContent", - __name: "contactsOnBroadcastsModel", }, })) @@ -164,11 +143,12 @@ const makeBroadcast = (overrides: Record = {}) => ({ // ── setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { - updateCalls.length = 0 + markContactFailedCalls.length = 0 + blockedOwnerGuardBlocked.blocked = false vi.clearAllMocks() - findManyBroadcast.mockResolvedValue([]) - findManyContactsOnBroadcasts.mockResolvedValue([]) - updateWhereSpy.mockResolvedValue(undefined) + listSendableById.mockResolvedValue([]) + listPendingRecipients.mockResolvedValue([]) + markContactFailedSpy.mockResolvedValue(undefined) chatAddSpy.mockResolvedValue(undefined) integrationAddSpy.mockResolvedValue(undefined) scheduleAddSpy.mockResolvedValue(undefined) @@ -182,21 +162,52 @@ beforeEach(() => { describe("processBroadcastContacts", () => { describe("no broadcasts in 'sending' status", () => { test("returns { processed: 0 } without any db updates or queue adds", async () => { - findManyBroadcast.mockResolvedValue([]) + listSendableById.mockResolvedValue([]) const result = await processBroadcastContacts(BROADCAST_ID) expect(result).toEqual({ processed: 0 }) - expect(updateCalls).toHaveLength(0) + expect(markContactFailedCalls).toHaveLength(0) expect(chatAddSpy).not.toHaveBeenCalled() expect(integrationAddSpy).not.toHaveBeenCalled() }) }) + // AGENTS.md invariant 15: a workspace-scoped worker must keep its + // blocked-owner guard. The refactor moved every db.* call behind + // broadcastService, so this pins that the guard still runs — and still runs + // on the broadcast's workspaceId, before any recipient is fetched. + describe("blocked-owner guard", () => { + test("still gates on the broadcast workspaceId before fetching recipients", async () => { + listSendableById.mockResolvedValue([makeBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) + + await processBroadcastContacts(BROADCAST_ID) + + expect(blockedOwnerGuard).toHaveBeenCalledWith("workspace-1") + }) + + test("returns { processed: 0 } and enqueues nothing when the owner is blocked", async () => { + listSendableById.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) + // A blocked owner makes the guard swallow the callback, so + // isBlockedWorkspace resolves true. + blockedOwnerGuardBlocked.blocked = true + + const result = await processBroadcastContacts(BROADCAST_ID) + + expect(result).toEqual({ processed: 0 }) + expect(listPendingRecipients).not.toHaveBeenCalled() + expect(integrationAddSpy).not.toHaveBeenCalled() + expect(chatAddSpy).not.toHaveBeenCalled() + expect(markContactFailedCalls).toHaveLength(0) + }) + }) + describe("broadcast has no unsent contacts", () => { test("stamps hand-off completion instead of a terminal status and returns processed: 0", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast()]) - findManyContactsOnBroadcasts.mockResolvedValue([]) + listSendableById.mockResolvedValue([makeBroadcast()]) + listPendingRecipients.mockResolvedValue([]) const result = await processBroadcastContacts(BROADCAST_ID) @@ -204,15 +215,15 @@ describe("processBroadcastContacts", () => { expect(markHandoffCompleted).toHaveBeenCalledWith({ broadcastId: BROADCAST_ID, }) - expect(updateCalls).toHaveLength(0) + expect(markContactFailedCalls).toHaveLength(0) expect(chatAddSpy).not.toHaveBeenCalled() }) }) describe("broadcast with flowId", () => { test("enqueues integrationQueue sendFlow with correct payload", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listSendableById.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -242,8 +253,8 @@ describe("processBroadcastContacts", () => { }) test("does not call chatQueue when only flowId is set", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listSendableById.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -255,10 +266,10 @@ describe("processBroadcastContacts", () => { "telegram", "tiktok", ] as const)("enqueues %s flow broadcasts through the integration queue only", async (channel) => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ flowId: "flow-1", channel }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -279,14 +290,14 @@ describe("processBroadcastContacts", () => { describe("broadcast with templateId on non-messenger channel", () => { test("enqueues chatQueue sendWhatsappTemplateMessage with correct payload", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp", templateData: { components: [] }, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -315,14 +326,14 @@ describe("processBroadcastContacts", () => { describe("broadcast with templateId on messenger channel", () => { test("enqueues chatQueue sendMessengerTemplateMessage", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-messenger", channel: "messenger", templateData: { text: "Hello" }, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -339,14 +350,14 @@ describe("processBroadcastContacts", () => { test("separates buttons from templateData so job receives correct shapes", async () => { const buttons = [{ id: "b1", label: "Yes", flowId: "flow-btn" }] - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-messenger", channel: "messenger", templateData: { text: "Pick one", buttons }, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -360,14 +371,14 @@ describe("processBroadcastContacts", () => { }) test("templateData is undefined when no non-button fields are present", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-messenger", channel: "messenger", templateData: { buttons: [{ id: "b1", label: "Yes" }] }, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -381,53 +392,39 @@ describe("processBroadcastContacts", () => { describe("successful contact processing", () => { test("scopes broadcast lookup when broadcastId is provided", async () => { - findManyBroadcast.mockResolvedValue([]) + listSendableById.mockResolvedValue([]) await processBroadcastContacts("broadcast-filter") - expect(findManyBroadcast).toHaveBeenCalledWith({ - where: { - id: "broadcast-filter", - status: "sending", - deletedAt: { isNull: true }, - }, - with: { - targets: { - columns: { - inboxId: true, - flowId: true, - templateId: true, - templateData: true, - }, - }, - }, + // The status/deletedAt predicates now live inside + // broadcastService.listSendableById (pinned in + // packages/business/__tests__/broadcast-service-prepare.test.ts); the + // handler's job is to scope the lookup to the requested broadcast. + expect(listSendableById).toHaveBeenCalledWith({ + broadcastId: "broadcast-filter", }) }) test("fetches only unsent contacts that are not terminal-failed", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast()]) + listSendableById.mockResolvedValue([makeBroadcast()]) await processBroadcastContacts(BROADCAST_ID) - expect(findManyContactsOnBroadcasts).toHaveBeenCalledWith({ - where: { - broadcastId: BROADCAST_ID, - sent: false, - failedAt: { isNull: true }, - }, - with: { - conversation: true, - contactInbox: true, - }, + // The sent/failedAt predicates and the conversation/contactInbox + // relations now live inside broadcastService.listPendingRecipients + // (pinned in packages/business/__tests__/broadcast-service-prepare.test.ts); + // the handler must still page at the 500-row rate limit. + expect(listPendingRecipients).toHaveBeenCalledWith({ + broadcastId: BROADCAST_ID, limit: 500, }) }) test("marks contactOnBroadcast sent via markContactSentIfSending after queue add (guard-vs-producer race: the service's own EXISTS guard — not a caller-side status check — is what keeps a stopped broadcast's row from resurrecting)", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) const result = await processBroadcastContacts(BROADCAST_ID) @@ -436,22 +433,16 @@ describe("processBroadcastContacts", () => { broadcastId: BROADCAST_ID, contactId: "contact-1", }) - // No raw db.update for the sent flag anymore — it goes through the - // conditional service call above. - expect( - updateCalls.some( - (c) => - (c.table as { __name?: string }).__name === - "contactsOnBroadcastsModel" && c.values.sent === true, - ), - ).toBe(false) + // The sent flag goes through the conditional service call above — the + // handler never marks this recipient failed. + expect(markContactFailedCalls).toHaveLength(0) }) test("processes multiple contacts in the scoped broadcast and returns total count", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "t-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast(), makeContactOnBroadcast({ contactId: "contact-2", @@ -469,10 +460,10 @@ describe("processBroadcastContacts", () => { }) test("does not requeue or finalize on full batch because cron drives the next batch", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue( + listPendingRecipients.mockResolvedValue( Array.from({ length: 500 }, (_, index) => makeContactOnBroadcast({ contactId: `contact-${index}`, @@ -485,20 +476,18 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 500 }) expect(scheduleAddSpy).not.toHaveBeenCalled() - expect( - updateCalls.some( - (call) => - (call.table as { __name?: string }).__name === "broadcastModel" && - call.values.status === "sent", - ), - ).toBe(false) + // The handler never promotes the broadcast to a terminal "sent" status; + // only the hand-off stamp is written. `broadcastService` exposes no + // status-setting method to this handler, so the only writes it can make + // are the hand-off stamp, the per-contact sent flag, and markContactFailed. + expect(markContactFailedSpy).not.toHaveBeenCalled() }) test("stamps hand-off completion for a partial batch with no retryable error", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -506,22 +495,20 @@ describe("processBroadcastContacts", () => { expect(markHandoffCompleted).toHaveBeenCalledWith({ broadcastId: BROADCAST_ID, }) - expect( - updateCalls.some( - (call) => - (call.table as { __name?: string }).__name === "broadcastModel" && - call.values.status === "sent", - ), - ).toBe(false) + // The handler never promotes the broadcast to a terminal "sent" status; + // only the hand-off stamp is written. `broadcastService` exposes no + // status-setting method to this handler, so the only writes it can make + // are the hand-off stamp, the per-contact sent flag, and markContactFailed. + expect(markContactFailedSpy).not.toHaveBeenCalled() }) }) describe("error handling inside per-contact processing", () => { test("throws when queue.add fails so BullMQ can retry and does not mark failedAt", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) const error = new Error("queue unavailable") chatAddSpy.mockRejectedValueOnce(error) @@ -531,18 +518,14 @@ describe("processBroadcastContacts", () => { ) expect(loggerErrorSpy).toHaveBeenCalledTimes(1) - expect(updateCalls).not.toContainEqual( - expect.objectContaining({ - values: expect.objectContaining({ failedAt: expect.anything() }), - }), - ) + expect(markContactFailedSpy).not.toHaveBeenCalled() }) test("throws when markContactSentIfSending fails after enqueue and does not mark failedAt", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) markContactSentIfSending.mockRejectedValueOnce( new Error("database unavailable"), @@ -553,16 +536,12 @@ describe("processBroadcastContacts", () => { ) expect(chatAddSpy).toHaveBeenCalledTimes(1) - expect(updateCalls).not.toContainEqual( - expect.objectContaining({ - values: expect.objectContaining({ failedAt: expect.anything() }), - }), - ) + expect(markContactFailedSpy).not.toHaveBeenCalled() }) test("marks invalid flow contact failed without throwing or enqueueing", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listSendableById.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ conversationId: "" }), ]) @@ -570,18 +549,15 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 0 }) expect(integrationAddSpy).not.toHaveBeenCalled() - expect(updateCalls).toContainEqual( - expect.objectContaining({ - values: expect.objectContaining({ - failedAt: expect.anything(), - errorContent: "missing conversation for flow send", - }), - }), - ) + expect(markContactFailedCalls).toContainEqual({ + broadcastId: BROADCAST_ID, + contactId: "contact-1", + reason: "missing conversation for flow send", + }) }) test("sends each contact with the template chosen for its own page", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", targetMode: "targets", @@ -599,7 +575,7 @@ describe("processBroadcastContacts", () => { ], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactId: "contact-a", contactInbox: makeContactInbox("ci-a", "inbox-a"), @@ -631,11 +607,11 @@ describe("processBroadcastContacts", () => { }), ]), ) - expect(updateCalls).toHaveLength(0) + expect(markContactFailedCalls).toHaveLength(0) }) test("separates per-page Messenger buttons from the target's template params", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "messenger", targetMode: "targets", @@ -651,7 +627,7 @@ describe("processBroadcastContacts", () => { ], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactInbox: makeContactInbox("ci-a", "inbox-a"), }), @@ -673,7 +649,7 @@ describe("processBroadcastContacts", () => { }) test("marks a contact failed when its page has no template in a multi-page broadcast", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", targetMode: "targets", @@ -686,7 +662,7 @@ describe("processBroadcastContacts", () => { ], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactId: "contact-other", contactInbox: makeContactInbox("ci-x", "inbox-other"), @@ -698,14 +674,14 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 0 }) expect(chatAddSpy).not.toHaveBeenCalled() expect(markContactSentIfSending).not.toHaveBeenCalled() - expect(updateCalls).toHaveLength(1) - expect(updateCalls[0].values.errorContent).toBe( + expect(markContactFailedCalls).toHaveLength(1) + expect(markContactFailedCalls[0].reason).toBe( "no template selected for the contact's page", ) }) test("fails every contact of a targets-mode broadcast whose target rows are gone, without touching legacy columns", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", targetMode: "targets", @@ -714,7 +690,7 @@ describe("processBroadcastContacts", () => { targets: [], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactInbox: makeContactInbox("ci-a", "inbox-a"), }), @@ -724,13 +700,13 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 0 }) expect(chatAddSpy).not.toHaveBeenCalled() - expect(updateCalls[0].values.errorContent).toBe( + expect(markContactFailedCalls[0].reason).toBe( "no template selected for the contact's page", ) }) test("runs each page's own flow in a targets-mode flow broadcast", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", targetMode: "targets", @@ -750,7 +726,7 @@ describe("processBroadcastContacts", () => { ], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactId: "contact-a", contactInbox: makeContactInbox("ci-a", "inbox-a"), @@ -772,7 +748,7 @@ describe("processBroadcastContacts", () => { }) test("fails a contact whose page lost its flow (deleted → set null) instead of marking it sent", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", targetMode: "targets", @@ -786,7 +762,7 @@ describe("processBroadcastContacts", () => { ], }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactInbox: makeContactInbox("ci-a", "inbox-a"), }), @@ -797,20 +773,20 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 0 }) expect(integrationAddSpy).not.toHaveBeenCalled() expect(markContactSentIfSending).not.toHaveBeenCalled() - expect(updateCalls[0].values.errorContent).toBe( + expect(markContactFailedCalls[0].reason).toBe( "no flow or template selected for the contact's page", ) }) test("keeps the legacy single-template path when the broadcast has no targets", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ channel: "whatsapp", templateId: "legacy-template", templateData: { body: ["legacy"] }, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ contactInbox: makeContactInbox("ci-a", "inbox-any"), }), @@ -831,10 +807,10 @@ describe("processBroadcastContacts", () => { }) test("marks invalid template contact failed without throwing or enqueueing", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ conversation: null }), ]) @@ -842,22 +818,21 @@ describe("processBroadcastContacts", () => { expect(result).toEqual({ processed: 0 }) expect(chatAddSpy).not.toHaveBeenCalled() - expect(updateCalls).toContainEqual( - expect.objectContaining({ - values: expect.objectContaining({ - failedAt: expect.anything(), - errorContent: "missing conversation/contactInbox for template send", - }), - }), - ) + expect(markContactFailedCalls).toContainEqual({ + broadcastId: BROADCAST_ID, + contactId: "contact-1", + reason: "missing conversation/contactInbox for template send", + }) }) test("throws when marking invalid contact failed hits a database error", async () => { - findManyBroadcast.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) - findManyContactsOnBroadcasts.mockResolvedValue([ + listSendableById.mockResolvedValue([makeBroadcast({ flowId: "flow-1" })]) + listPendingRecipients.mockResolvedValue([ makeContactOnBroadcast({ conversationId: "" }), ]) - updateWhereSpy.mockRejectedValueOnce(new Error("database unavailable")) + markContactFailedSpy.mockRejectedValueOnce( + new Error("database unavailable"), + ) await expect(processBroadcastContacts(BROADCAST_ID)).rejects.toThrow( "database unavailable", @@ -873,15 +848,34 @@ describe("processBroadcastContacts", () => { ) }) + // A BullMQ jobId containing ":" collides with Redis key namespacing and + // silently breaks dedup — see the worker-development skill. + test("every downstream jobId is free of the ':' Redis key separator", async () => { + listSendableById.mockResolvedValue([ + makeBroadcast({ flowId: "flow-1", templateId: "tmpl-1" }), + ]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) + + await processBroadcastContacts(BROADCAST_ID) + + const jobIds = [...chatAddSpy.mock.calls, ...integrationAddSpy.mock.calls] + .map((call) => (call[2] as { jobId?: string } | undefined)?.jobId) + .filter((jobId): jobId is string => typeof jobId === "string") + expect(jobIds.length).toBeGreaterThan(0) + for (const jobId of jobIds) { + expect(jobId).not.toContain(":") + } + }) + test("enqueues flow and template with distinct deterministic jobIds", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ flowId: "flow-1", templateId: "tmpl-1", channel: "whatsapp", }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -904,14 +898,14 @@ describe("processBroadcastContacts", () => { }) test("retries both flow and template with the same deterministic jobIds", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ flowId: "flow-1", templateId: "tmpl-1", channel: "whatsapp", }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) markContactSentIfSending .mockRejectedValueOnce(new Error("database unavailable")) .mockResolvedValue(undefined) @@ -946,14 +940,14 @@ describe("processBroadcastContacts", () => { }) test("never emits a downstream jobId containing ':' (BullMQ rejects it)", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ flowId: "flow-1", templateId: "tmpl-1", channel: "whatsapp", }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -972,10 +966,10 @@ describe("processBroadcastContacts", () => { describe("hand-off completion", () => { test("does not stamp hand-off when a batch throws part-way (reconcile re-drives it)", async () => { // Same fixture and queue spy as the existing "Retryable error" test in this file. - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp" }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) chatAddSpy.mockRejectedValueOnce(new Error("queue unavailable")) await expect(processBroadcastContacts(BROADCAST_ID)).rejects.toThrow( @@ -993,7 +987,7 @@ describe("processBroadcastContacts", () => { // may still be sitting in the queue's 1h removeOnComplete retention // window (see broadcastContactSendJobId's comment in the source file). test("suffixes downstream jobIds with the broadcast row's resumeCount", async () => { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ flowId: "flow-1", templateId: "tmpl-1", @@ -1001,7 +995,7 @@ describe("processBroadcastContacts", () => { resumeCount: 2, }), ]) - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) await processBroadcastContacts(BROADCAST_ID) @@ -1022,10 +1016,10 @@ describe("processBroadcastContacts", () => { }) test("a second resume (resumeCount goes 0 -> 1 -> 2) produces a third, still-distinct jobId epoch", async () => { - findManyContactsOnBroadcasts.mockResolvedValue([makeContactOnBroadcast()]) + listPendingRecipients.mockResolvedValue([makeContactOnBroadcast()]) for (const resumeCount of [0, 1, 2]) { - findManyBroadcast.mockResolvedValue([ + listSendableById.mockResolvedValue([ makeBroadcast({ templateId: "tmpl-1", channel: "whatsapp", diff --git a/apps/worker/__tests__/reconcile-broadcasts.test.ts b/apps/worker/__tests__/reconcile-broadcasts.test.ts index 4bee875e86..c3ffee4a36 100644 --- a/apps/worker/__tests__/reconcile-broadcasts.test.ts +++ b/apps/worker/__tests__/reconcile-broadcasts.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ── db spies ────────────────────────────────────────────────────────────────── -const findManyBroadcast = vi.fn() +// ── service spy ─────────────────────────────────────────────────────────────── +const listSendingAwaitingHandoff = vi.fn() // ── queue spies ─────────────────────────────────────────────────────────────── const scheduleAddSpy = vi.fn() @@ -10,19 +10,10 @@ const scheduleAddSpy = vi.fn() const runExclusiveSpy = vi.fn() // ── mocks ───────────────────────────────────────────────────────────────────── -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findMany: (...args: unknown[]) => findManyBroadcast(...args), - }, - }, - }, -})) - -vi.mock("@chatbotx.io/database/partials", () => ({ - broadcastStatuses: { - enum: { scheduled: "scheduled", sending: "sending", sent: "sent" }, +vi.mock("@chatbotx.io/business", () => ({ + broadcastService: { + listSendingAwaitingHandoff: (...args: unknown[]) => + listSendingAwaitingHandoff(...args), }, })) @@ -56,7 +47,7 @@ const { reconcileBroadcasts } = await import( // ── setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { vi.clearAllMocks() - findManyBroadcast.mockResolvedValue([]) + listSendingAwaitingHandoff.mockResolvedValue([]) scheduleAddSpy.mockResolvedValue(undefined) }) @@ -74,18 +65,12 @@ describe("reconcileBroadcasts", () => { }) test("enqueues one sendBroadcast revive job per sending broadcast", async () => { - findManyBroadcast.mockResolvedValue([{ id: "b-1" }, { id: "b-2" }]) + listSendingAwaitingHandoff.mockResolvedValue([{ id: "b-1" }, { id: "b-2" }]) const result = await reconcileBroadcasts() expect(result).toEqual({ reconciled: 2 }) - expect(findManyBroadcast).toHaveBeenCalledWith({ - where: { - status: "sending", - handoffCompletedAt: { isNull: true }, - deletedAt: { isNull: true }, - }, - }) + expect(listSendingAwaitingHandoff).toHaveBeenCalledTimes(1) expect(scheduleAddSpy).toHaveBeenCalledTimes(2) expect(scheduleAddSpy).toHaveBeenNthCalledWith( 1, @@ -125,7 +110,7 @@ describe("reconcileBroadcasts", () => { }) test("uses a jobId free of ':' (BullMQ rejects custom ids containing ':')", async () => { - findManyBroadcast.mockResolvedValue([{ id: "b-1" }]) + listSendingAwaitingHandoff.mockResolvedValue([{ id: "b-1" }]) await reconcileBroadcasts() diff --git a/apps/worker/__tests__/sequence-dispatch-processor.test.ts b/apps/worker/__tests__/sequence-dispatch-processor.test.ts index 6179d4d4d1..ff4a70893b 100644 --- a/apps/worker/__tests__/sequence-dispatch-processor.test.ts +++ b/apps/worker/__tests__/sequence-dispatch-processor.test.ts @@ -1,46 +1,15 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ---------- db chain spies ---------- -const findFirstSpy = vi.fn() -const updateSpy = vi.fn() -const setSpy = vi.fn() -const whereSpy = vi.fn() -const returningSpy = vi.fn() +const findWithRelationsSpy = vi.fn() +const claimSpy = vi.fn() const { loggerErrorSpy } = vi.hoisted(() => ({ loggerErrorSpy: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - sequenceDispatchModel: { - findFirst: (...args: unknown[]) => findFirstSpy(...args), - }, - }, - update: (table: unknown) => { - updateSpy(table) - return { - set: (values: unknown) => { - setSpy(values) - return { - where: (...args: unknown[]) => { - whereSpy(...args) - return { returning: (...a: unknown[]) => returningSpy(...a) } - }, - } - }, - } - }, - }, - 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" }, +vi.mock("@chatbotx.io/database/repositories", () => ({ + sequenceDispatchRepository: { + findWithRelations: (...args: unknown[]) => findWithRelationsSpy(...args), + claim: (...args: unknown[]) => claimSpy(...args), }, })) @@ -70,18 +39,20 @@ function makeDispatch(overrides: Record = {}) { beforeEach(() => { vi.restoreAllMocks() loggerErrorSpy.mockReset() - findFirstSpy.mockResolvedValue(undefined) - returningSpy.mockResolvedValue([]) + findWithRelationsSpy.mockReset() + claimSpy.mockReset() + findWithRelationsSpy.mockResolvedValue(null) + claimSpy.mockResolvedValue(false) }) // ---------- tests ---------- describe("DispatchProcessorService", () => { describe("fetchDispatch", () => { - test("returns the dispatch when db finds a record", async () => { + test("returns the dispatch when the repository finds a record", async () => { // Arrange const dispatch = makeDispatch() - findFirstSpy.mockResolvedValue(dispatch) + findWithRelationsSpy.mockResolvedValue(dispatch) // Act const result = await new DispatchProcessorService().fetchDispatch( @@ -94,9 +65,9 @@ describe("DispatchProcessorService", () => { expect(result).toEqual(dispatch) }) - test("returns null when db returns undefined (not found)", async () => { + test("returns null when the repository returns null (not found)", async () => { // Arrange - findFirstSpy.mockResolvedValue(undefined) + findWithRelationsSpy.mockResolvedValue(null) // Act const result = await new DispatchProcessorService().fetchDispatch( @@ -109,10 +80,10 @@ describe("DispatchProcessorService", () => { expect(result).toBeNull() }) - test("returns null and logs error when db throws", async () => { + test("returns null and logs error when the repository throws", async () => { // Arrange const consoleSpy = vi.spyOn(console, "error") - findFirstSpy.mockRejectedValue(new Error("connection refused")) + findWithRelationsSpy.mockRejectedValue(new Error("connection refused")) // Act const result = await new DispatchProcessorService().fetchDispatch( @@ -130,9 +101,9 @@ describe("DispatchProcessorService", () => { ) }) - test("queries with id + status + workspaceId and fetches sequence/contact/enrollment relations", async () => { + test("queries with id + status + workspaceId", async () => { // Arrange - findFirstSpy.mockResolvedValue({ id: "d99" }) + findWithRelationsSpy.mockResolvedValue({ id: "d99" }) // Act await new DispatchProcessorService().fetchDispatch( @@ -142,16 +113,11 @@ describe("DispatchProcessorService", () => { ) // Assert - expect(findFirstSpy).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: "d99", - status: "pending", - workspaceId: "ws1", - }), - with: { sequence: true, contact: true, enrollment: true }, - }), - ) + expect(findWithRelationsSpy).toHaveBeenCalledWith({ + id: "d99", + status: "pending", + workspaceId: "ws1", + }) }) }) @@ -240,9 +206,9 @@ describe("DispatchProcessorService", () => { }) describe("lockDispatch", () => { - test("returns true when a row is updated (lock acquired)", async () => { + test("returns true when the repository acquires the lock", async () => { // Arrange - returningSpy.mockResolvedValue([{ id: "d1" }]) + claimSpy.mockResolvedValue(true) const dispatch = makeDispatch() as NonNullable // Act @@ -254,9 +220,9 @@ describe("DispatchProcessorService", () => { expect(result).toBe(true) }) - test("returns false when no rows updated — optimistic lock lost", async () => { + test("returns false when the repository fails to acquire the lock", async () => { // Arrange - returningSpy.mockResolvedValue([]) + claimSpy.mockResolvedValue(false) const dispatch = makeDispatch() as NonNullable // Act @@ -268,40 +234,22 @@ describe("DispatchProcessorService", () => { expect(result).toBe(false) }) - test("sets status to running with a fresh lockedAt timestamp", async () => { + test("delegates to the repository with id, workspaceId, and a lock owner", async () => { // Arrange - returningSpy.mockResolvedValue([{ id: "d1" }]) + claimSpy.mockResolvedValue(true) const dispatch = makeDispatch() as NonNullable // Act - const before = Date.now() await new DispatchProcessorService().lockDispatch( dispatch as NonNullable, ) - const after = Date.now() // Assert - const setArg = setSpy.mock.calls[0][0] as Record - expect(setArg.status).toBe("running") - expect(setArg.lockedAt).toBeInstanceOf(Date) - const ts = (setArg.lockedAt as Date).getTime() - expect(ts).toBeGreaterThanOrEqual(before) - expect(ts).toBeLessThanOrEqual(after) - }) - - test("WHERE clause uses id + workspaceId + status=pending for optimistic concurrency", async () => { - // Arrange - returningSpy.mockResolvedValue([]) - const dispatch = makeDispatch() as NonNullable - - // Act - await new DispatchProcessorService().lockDispatch( - dispatch as NonNullable, - ) - - // Assert — three conditions prevent double-claiming - const whereArg = whereSpy.mock.calls[0][0] as { __and: unknown[] } - expect(whereArg.__and).toHaveLength(3) + expect(claimSpy).toHaveBeenCalledWith({ + id: "d1", + workspaceId: "ws1", + lockOwner: expect.any(String), + }) }) }) }) diff --git a/apps/worker/__tests__/sync-channel-labels.test.ts b/apps/worker/__tests__/sync-channel-labels.test.ts index 28bc57219f..f6a5d27575 100644 --- a/apps/worker/__tests__/sync-channel-labels.test.ts +++ b/apps/worker/__tests__/sync-channel-labels.test.ts @@ -3,15 +3,23 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // --------------------------------------------------------------------------- // Design notes: // +// The handler no longer touches `db.*` directly — it calls +// `contactInboxRepository.listByInboxPage`, `integrationMessengerRepository +// .findById`, `zaloIntegrationService.findByIdUnscoped`, and +// `tagChannelRepository.upsertLabelMapping`. The SQL-shape assertions for +// `upsertLabelMapping` itself (insert order, onConflict targets, early +// returns) already live in +// `packages/database/__tests__/tag-channel-repository.test.ts` — this file +// only asserts the handler calls that repository method with the right +// arguments, and preserves every handler-level behavior (routing, scan +// pagination, per-user error isolation, error-log collapsing). +// // - chunkById is mocked to call queryBuilder(null) then stop (single chunk). // Dedicated pagination tests override this mock per-test with a two-call -// sequence so we can assert cursor-pagination args against findMany. -// - Each insert builder is a shared chainable stub; state.* slots let tests -// override what .returning() resolves to. -// - insertCalls[] tracks the table-name sequence across a single test run and -// is reset in beforeEach. -// - vi.mock() factories run once (hoisted), so state mutations happen through -// the shared `state` object — NOT through re-declaring mocks. +// sequence so we can assert cursor-pagination args against +// contactInboxRepository.listByInboxPage. +// - upsertLabelMappingCalls[] tracks call arguments in order per test and is +// reset in beforeEach. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -20,93 +28,39 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const state = { messengerIntegration: null as Record | null, zaloIntegration: null as Record | null, - // findMany returns this list once, then returns [] on subsequent calls - // (unless a test overrides the spy directly). + // listByInboxPage returns this list once, then returns [] on subsequent + // calls (unless a test overrides the spy directly). contactInboxRows: [] as Record[], - tagReturning: [{ id: "tag-1" }] as { id: string }[], - tagChannelReturning: [{ id: "tc-1" }] as { id: string }[], } -// Track insert table names in call order. -const insertCalls: string[] = [] - // --------------------------------------------------------------------------- -// Chainable insert builder — shared instances, returning-result driven by state +// Mock: @chatbotx.io/database/repositories // --------------------------------------------------------------------------- -type InsertBuilder = { - values: ReturnType - onConflictDoUpdate: ReturnType - onConflictDoNothing: ReturnType - returning: ReturnType -} - -function makeBuilder(getResult: () => unknown[]): InsertBuilder { - const b = {} as InsertBuilder - b.values = vi.fn(() => b) - b.onConflictDoUpdate = vi.fn(() => b) - b.onConflictDoNothing = vi.fn(() => Promise.resolve([])) - b.returning = vi.fn(() => Promise.resolve(getResult())) - return b -} - -const tagBuilder = makeBuilder(() => state.tagReturning) -const tagChannelBuilder = makeBuilder(() => state.tagChannelReturning) -const contactsToTagsBuilder = makeBuilder(() => []) -const contactToTagChannelBuilder = makeBuilder(() => []) +const listByInboxPageSpy = vi.fn() +const upsertLabelMappingSpy = vi.fn(async () => undefined) +const findMessengerByIdSpy = vi.fn(async () => state.messengerIntegration) -// --------------------------------------------------------------------------- -// Mock: @chatbotx.io/database/client -// --------------------------------------------------------------------------- -const findManySpy = vi.fn() - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - integrationMessengerModel: { - findFirst: vi.fn(async () => state.messengerIntegration), - }, - integrationZaloModel: { - findFirst: vi.fn(async () => state.zaloIntegration), - }, - contactInboxModel: { - findMany: findManySpy, - }, - }, - insert: vi.fn((model: { tableName?: string }) => { - const name = model.tableName ?? String(model) - insertCalls.push(name) - if (name === "Tag") { - return tagBuilder - } - if (name === "TagChannel") { - return tagChannelBuilder - } - if (name === "ContactToTag") { - return contactsToTagsBuilder - } - if (name === "ContactToTagChannel") { - return contactToTagChannelBuilder - } - return tagBuilder - }), +vi.mock("@chatbotx.io/database/repositories", () => ({ + contactInboxRepository: { + listByInboxPage: (...args: unknown[]) => listByInboxPageSpy(...args), + }, + integrationMessengerRepository: { + findById: (...args: unknown[]) => findMessengerByIdSpy(...args), + }, + tagChannelRepository: { + upsertLabelMapping: (...args: unknown[]) => upsertLabelMappingSpy(...args), }, - sql: vi.fn((strings: TemplateStringsArray) => strings.raw.join("")), - isNull: (...args: unknown[]) => args, })) // --------------------------------------------------------------------------- -// Mock: @chatbotx.io/database/schema +// Mock: @chatbotx.io/business — buildContext + zaloIntegrationService // --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { tableName: "Tag", workspaceId: "workspaceId", name: "name" }, - tagChannelModel: { - tableName: "TagChannel", - tagId: "tagId", - channelType: "channelType", - integrationId: "integrationId", +const findZaloUnscopedSpy = vi.fn(async () => state.zaloIntegration) +vi.mock("@chatbotx.io/business", () => ({ + buildContext: vi.fn(async () => ({ ctx: "mocked-context" })), + zaloIntegrationService: { + findByIdUnscoped: (...args: unknown[]) => findZaloUnscopedSpy(...args), }, - contactsToTagsModel: { tableName: "ContactToTag" }, - contactToTagChannelModel: { tableName: "ContactToTagChannel" }, })) // --------------------------------------------------------------------------- @@ -158,13 +112,6 @@ vi.mock("@chatbotx.io/integration-zalo", () => ({ integration: { runAction: runActionMock }, })) -// --------------------------------------------------------------------------- -// Mock: @chatbotx.io/business -// --------------------------------------------------------------------------- -vi.mock("@chatbotx.io/business", () => ({ - buildContext: vi.fn(async () => ({ ctx: "mocked-context" })), -})) - // --------------------------------------------------------------------------- // Mock: @chatbotx.io/business/error-log // --------------------------------------------------------------------------- @@ -173,17 +120,9 @@ vi.mock("@chatbotx.io/business/error-log", () => ({ logProviderError: (...args: unknown[]) => logProviderError(...args), })) -// --------------------------------------------------------------------------- -// Mock: @chatbotx.io/utils — partial, preserve zodBigintAsString etc. -// --------------------------------------------------------------------------- -let idCounter = 0 -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createId: vi.fn(() => `gen-id-${++idCounter}`), - } -}) +vi.mock("../src/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) // --------------------------------------------------------------------------- // Import SUT — AFTER all vi.mock() calls @@ -254,18 +193,26 @@ beforeEach(() => { state.messengerIntegration = null state.zaloIntegration = null state.contactInboxRows = [] - state.tagReturning = [{ id: "tag-1" }] - state.tagChannelReturning = [{ id: "tc-1" }] - insertCalls.length = 0 - idCounter = 0 chunkByIdImpl = singleChunkImpl - // Default findMany: return rows on first call, [] on subsequent calls. - findManySpy.mockImplementation(() => { + listByInboxPageSpy.mockReset() + upsertLabelMappingSpy.mockReset() + findMessengerByIdSpy.mockReset() + findZaloUnscopedSpy.mockReset() + runChannelHandlerMock.mockReset() + runActionMock.mockReset() + logProviderError.mockReset() + + upsertLabelMappingSpy.mockResolvedValue(undefined) + findMessengerByIdSpy.mockImplementation( + async () => state.messengerIntegration, + ) + findZaloUnscopedSpy.mockImplementation(async () => state.zaloIntegration) + + // Default listByInboxPage: return rows on first call, [] on subsequent + // calls (unless a test overrides the spy directly). + listByInboxPageSpy.mockImplementation(() => { const rows = state.contactInboxRows - // After the first call resolves, swap to returning [] so pagination stops. - // We use mockImplementationOnce to override just the first call; the - // fallback is already the singleChunk behaviour but we keep it explicit. return Promise.resolve(rows) }) }) @@ -283,7 +230,7 @@ describe("handleSyncChannelLabels — routing", () => { ).resolves.toBeUndefined() expect(runChannelHandlerMock).not.toHaveBeenCalled() - expect(insertCalls).toHaveLength(0) + expect(upsertLabelMappingSpy).not.toHaveBeenCalled() }) test("zalo integration not found → warns and returns without scanning", async () => { @@ -292,35 +239,29 @@ describe("handleSyncChannelLabels — routing", () => { await expect(handleSyncChannelLabels(zaloJob())).resolves.toBeUndefined() expect(runActionMock).not.toHaveBeenCalled() - expect(insertCalls).toHaveLength(0) + expect(upsertLabelMappingSpy).not.toHaveBeenCalled() }) - test("messenger route queries integrationMessengerModel.findFirst with correct integrationId", async () => { + test("messenger route queries integrationMessengerRepository.findById with correct integrationId", async () => { state.messengerIntegration = makeMessengerIntegration() - const { db } = await import("@chatbotx.io/database/client") - const spy = db.query.integrationMessengerModel.findFirst as ReturnType< - typeof vi.fn - > - spy.mockClear() await handleSyncChannelLabels(messengerJob("integration-msn-1")) - expect(spy).toHaveBeenCalledOnce() - expect(spy).toHaveBeenCalledWith({ where: { id: "integration-msn-1" } }) + expect(findMessengerByIdSpy).toHaveBeenCalledOnce() + expect(findMessengerByIdSpy).toHaveBeenCalledWith({ + id: "integration-msn-1", + }) }) - test("zalo route queries integrationZaloModel.findFirst with correct integrationId", async () => { + test("zalo route queries zaloIntegrationService.findByIdUnscoped with correct integrationId", async () => { state.zaloIntegration = makeZaloIntegration() - const { db } = await import("@chatbotx.io/database/client") - const spy = db.query.integrationZaloModel.findFirst as ReturnType< - typeof vi.fn - > - spy.mockClear() await handleSyncChannelLabels(zaloJob("integration-zalo-1")) - expect(spy).toHaveBeenCalledOnce() - expect(spy).toHaveBeenCalledWith({ where: { id: "integration-zalo-1" } }) + expect(findZaloUnscopedSpy).toHaveBeenCalledOnce() + expect(findZaloUnscopedSpy).toHaveBeenCalledWith({ + id: "integration-zalo-1", + }) }) }) @@ -341,17 +282,17 @@ describe("runMessengerScan — listLabels happy path", () => { }) }) - test("listLabels returns [] → no insert calls", async () => { + test("listLabels returns [] → no upsertLabelMapping calls", async () => { state.messengerIntegration = makeMessengerIntegration() state.contactInboxRows = [makeContactInbox()] runChannelHandlerMock.mockResolvedValue([]) await handleSyncChannelLabels(messengerJob()) - expect(insertCalls).toHaveLength(0) + expect(upsertLabelMappingSpy).not.toHaveBeenCalled() }) - test("listLabels returns N labels → N tag inserts + N tagChannel inserts + N association inserts each", async () => { + test("listLabels returns N labels → N upsertLabelMapping calls", async () => { state.messengerIntegration = makeMessengerIntegration() state.contactInboxRows = [ makeContactInbox({ id: "ci-1", contactId: "contact-1" }), @@ -363,15 +304,10 @@ describe("runMessengerScan — listLabels happy path", () => { await handleSyncChannelLabels(messengerJob()) - expect(insertCalls.filter((n) => n === "Tag")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "TagChannel")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "ContactToTag")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "ContactToTagChannel")).toHaveLength( - 2, - ) + expect(upsertLabelMappingSpy).toHaveBeenCalledTimes(2) }) - test("externalLabelId in tagChannel insert equals the FB label id", async () => { + test("upsertLabelMapping called with FB label id as externalLabelId", async () => { state.messengerIntegration = makeMessengerIntegration({ id: "integration-msn-1", }) @@ -384,42 +320,31 @@ describe("runMessengerScan — listLabels happy path", () => { await handleSyncChannelLabels(messengerJob()) - expect(tagChannelBuilder.values).toHaveBeenCalledWith( + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( expect.objectContaining({ - externalLabelId: "fb-label-42", + workspaceId: "ws-1", channelType: "messenger", integrationId: "integration-msn-1", - workspaceId: "ws-1", - tagId: "tag-1", + label: { externalLabelId: "fb-label-42", name: "VIP" }, + contactInbox: { id: "ci-1", contactId: "contact-1" }, }), ) }) - test("contactsToTags insert uses correct contactId and tagId", async () => { + test("upsertLabelMapping uses correct contactId and contactInboxId", async () => { state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox({ contactId: "contact-99" })] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-42", name: "Gold" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(contactsToTagsBuilder.values).toHaveBeenCalledWith({ - contactId: "contact-99", - tagId: "tag-1", - }) - }) - - test("contactToTagChannel insert uses correct tagId, tagChannelId, contactInboxId", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox({ id: "ci-77" })] + state.contactInboxRows = [ + makeContactInbox({ id: "ci-77", contactId: "contact-99" }), + ] runChannelHandlerMock.mockResolvedValue([{ id: "fb-42", name: "Gold" }]) await handleSyncChannelLabels(messengerJob()) - expect(contactToTagChannelBuilder.values).toHaveBeenCalledWith({ - tagId: "tag-1", - tagChannelId: "tc-1", - contactInboxId: "ci-77", - }) + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + contactInbox: { id: "ci-77", contactId: "contact-99" }, + }), + ) }) test("multiple contacts → listLabels called once per contact with each sourceId", async () => { @@ -516,81 +441,9 @@ describe("runMessengerScan — per-user error isolation", () => { }) }) -// --------------------------------------------------------------------------- -describe("upsertLabelMapping — early-return branches", () => { - test("tag insert returns [] → tagChannel NOT inserted (early return)", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - state.tagReturning = [] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(insertCalls).toEqual(["Tag"]) - expect(insertCalls.filter((n) => n === "TagChannel")).toHaveLength(0) - expect(insertCalls.filter((n) => n === "ContactToTag")).toHaveLength(0) - expect(insertCalls.filter((n) => n === "ContactToTagChannel")).toHaveLength( - 0, - ) - }) - - test("tagChannel insert returns [] → contactsToTags and contactToTagChannel NOT inserted", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - state.tagReturning = [{ id: "tag-1" }] - state.tagChannelReturning = [] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(insertCalls).toEqual(["Tag", "TagChannel"]) - expect(insertCalls.filter((n) => n === "ContactToTag")).toHaveLength(0) - expect(insertCalls.filter((n) => n === "ContactToTagChannel")).toHaveLength( - 0, - ) - }) - - test("happy path: inserts fire in order Tag → TagChannel → ContactToTag → ContactToTagChannel", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - state.tagReturning = [{ id: "tag-1" }] - state.tagChannelReturning = [{ id: "tc-1" }] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "Gold" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(insertCalls).toEqual([ - "Tag", - "TagChannel", - "ContactToTag", - "ContactToTagChannel", - ]) - }) - - test("contactsToTags uses onConflictDoNothing", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(contactsToTagsBuilder.onConflictDoNothing).toHaveBeenCalled() - }) - - test("contactToTagChannel uses onConflictDoNothing", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) - - await handleSyncChannelLabels(messengerJob()) - - expect(contactToTagChannelBuilder.onConflictDoNothing).toHaveBeenCalled() - }) -}) - // --------------------------------------------------------------------------- describe("workspace isolation", () => { - test("workspaceId is threaded into tag insert values", async () => { + test("workspaceId is threaded into upsertLabelMapping", async () => { state.messengerIntegration = makeMessengerIntegration({ id: "int-A" }) state.contactInboxRows = [makeContactInbox()] runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) @@ -601,12 +454,12 @@ describe("workspace isolation", () => { integrationId: "int-A", }) - expect(tagBuilder.values).toHaveBeenCalledWith( + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "ws-isolated" }), ) }) - test("workspaceId is threaded into tagChannel insert values", async () => { + test("integrationId is threaded into upsertLabelMapping", async () => { state.messengerIntegration = makeMessengerIntegration({ id: "int-A" }) state.contactInboxRows = [makeContactInbox()] runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) @@ -617,8 +470,8 @@ describe("workspace isolation", () => { integrationId: "int-A", }) - expect(tagChannelBuilder.values).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: "ws-isolated" }), + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: "int-A" }), ) }) }) @@ -644,34 +497,35 @@ describe("chunkById pagination — cursor behaviour", () => { } } - test("first findMany call uses no gt filter (lastId = null)", async () => { + test("first listByInboxPage call uses no afterId (lastId = null)", async () => { chunkByIdImpl = twoChunkImpl state.messengerIntegration = makeMessengerIntegration({ inboxId: "inbox-1", }) runChannelHandlerMock.mockResolvedValue([]) - findManySpy + listByInboxPageSpy .mockResolvedValueOnce([makeContactInbox({ id: "ci-1" })]) .mockResolvedValueOnce([]) await handleSyncChannelLabels(messengerJob()) - const firstCallArgs = findManySpy.mock.calls[0]?.[0] as { - where: { id?: { gt: string }; inboxId: string } + const firstCallArgs = listByInboxPageSpy.mock.calls[0]?.[0] as { + inboxId: string + afterId?: string } - expect(firstCallArgs.where).not.toHaveProperty("id") - expect(firstCallArgs.where.inboxId).toBe("inbox-1") + expect(firstCallArgs.afterId).toBeUndefined() + expect(firstCallArgs.inboxId).toBe("inbox-1") }) - test("second findMany call carries gt: last id from first batch", async () => { + test("second listByInboxPage call carries afterId: last id from first batch", async () => { chunkByIdImpl = twoChunkImpl state.messengerIntegration = makeMessengerIntegration({ inboxId: "inbox-1", }) runChannelHandlerMock.mockResolvedValue([]) - findManySpy + listByInboxPageSpy .mockResolvedValueOnce([ makeContactInbox({ id: "ci-10" }), makeContactInbox({ id: "ci-20" }), @@ -680,24 +534,24 @@ describe("chunkById pagination — cursor behaviour", () => { await handleSyncChannelLabels(messengerJob()) - const secondCallArgs = findManySpy.mock.calls[1]?.[0] as { - where: { id?: { gt: string } } + const secondCallArgs = listByInboxPageSpy.mock.calls[1]?.[0] as { + afterId?: string } - expect(secondCallArgs.where.id).toEqual({ gt: "ci-20" }) + expect(secondCallArgs.afterId).toBe("ci-20") }) - test("findMany is always scoped to the integration's inboxId", async () => { + test("listByInboxPage is always scoped to the integration's inboxId", async () => { chunkByIdImpl = twoChunkImpl state.messengerIntegration = makeMessengerIntegration({ inboxId: "inbox-XYZ", }) - findManySpy.mockResolvedValue([]) + listByInboxPageSpy.mockResolvedValue([]) await handleSyncChannelLabels(messengerJob()) - for (const call of findManySpy.mock.calls) { - const args = call[0] as { where: { inboxId: string } } - expect(args.where.inboxId).toBe("inbox-XYZ") + for (const call of listByInboxPageSpy.mock.calls) { + const args = call[0] as { inboxId: string } + expect(args.inboxId).toBe("inbox-XYZ") } }) @@ -705,7 +559,7 @@ describe("chunkById pagination — cursor behaviour", () => { chunkByIdImpl = twoChunkImpl state.messengerIntegration = makeMessengerIntegration() - findManySpy + listByInboxPageSpy .mockResolvedValueOnce([ makeContactInbox({ id: "ci-A", sourceId: "psid-A" }), ]) @@ -749,7 +603,7 @@ describe("runZaloScan — getUserDetail happy path", () => { await handleSyncChannelLabels(zaloJob()) - expect(insertCalls).toHaveLength(0) + expect(upsertLabelMappingSpy).not.toHaveBeenCalled() }) test("tags_and_notes_info present but tag_names undefined → no upsert", async () => { @@ -759,10 +613,10 @@ describe("runZaloScan — getUserDetail happy path", () => { await handleSyncChannelLabels(zaloJob()) - expect(insertCalls).toHaveLength(0) + expect(upsertLabelMappingSpy).not.toHaveBeenCalled() }) - test("tag_names array → one upsert mapping per tag name", async () => { + test("tag_names array → one upsertLabelMapping call per tag name", async () => { state.zaloIntegration = makeZaloIntegration() state.contactInboxRows = [ makeContactInbox({ @@ -777,15 +631,10 @@ describe("runZaloScan — getUserDetail happy path", () => { await handleSyncChannelLabels(zaloJob()) - expect(insertCalls.filter((n) => n === "Tag")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "TagChannel")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "ContactToTag")).toHaveLength(2) - expect(insertCalls.filter((n) => n === "ContactToTagChannel")).toHaveLength( - 2, - ) + expect(upsertLabelMappingSpy).toHaveBeenCalledTimes(2) }) - test("for zalo, externalLabelId in tagChannel equals the tag name string", async () => { + test("for zalo, externalLabelId in upsertLabelMapping equals the tag name string", async () => { state.zaloIntegration = makeZaloIntegration({ id: "integration-zalo-1" }) state.contactInboxRows = [makeContactInbox({ channel: "zalo" })] runActionMock.mockResolvedValue({ @@ -794,16 +643,16 @@ describe("runZaloScan — getUserDetail happy path", () => { await handleSyncChannelLabels(zaloJob()) - expect(tagChannelBuilder.values).toHaveBeenCalledWith( + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( expect.objectContaining({ - externalLabelId: "PremiumUser", channelType: "zalo", integrationId: "integration-zalo-1", + label: { externalLabelId: "PremiumUser", name: "PremiumUser" }, }), ) }) - test("for zalo, name in tag insert equals the tag name string", async () => { + test("for zalo, name in upsertLabelMapping equals the tag name string", async () => { state.zaloIntegration = makeZaloIntegration() state.contactInboxRows = [makeContactInbox({ channel: "zalo" })] runActionMock.mockResolvedValue({ @@ -812,8 +661,10 @@ describe("runZaloScan — getUserDetail happy path", () => { await handleSyncChannelLabels(zaloJob()) - expect(tagBuilder.values).toHaveBeenCalledWith( - expect.objectContaining({ name: "SpecialTag" }), + expect(upsertLabelMappingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + label: expect.objectContaining({ name: "SpecialTag" }), + }), ) }) }) @@ -881,44 +732,3 @@ describe("buildContext — integration type forwarding", () => { ) }) }) - -// --------------------------------------------------------------------------- -describe("createId — called for generated ids", () => { - test("createId called twice per label (tag row + tagChannel row)", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - runChannelHandlerMock.mockResolvedValue([ - { id: "fb-1", name: "Alpha" }, - { id: "fb-2", name: "Beta" }, - ]) - - const { createId } = await import("@chatbotx.io/utils") - const spy = createId as ReturnType - spy.mockClear() - - await handleSyncChannelLabels(messengerJob()) - - // 2 labels × 2 createId calls = 4 - expect(spy).toHaveBeenCalledTimes(4) - }) - - test("generated ids are injected into tag and tagChannel insert values", async () => { - state.messengerIntegration = makeMessengerIntegration() - state.contactInboxRows = [makeContactInbox()] - runChannelHandlerMock.mockResolvedValue([{ id: "fb-1", name: "VIP" }]) - - const { createId } = await import("@chatbotx.io/utils") - const spy = createId as ReturnType - spy.mockClear() - spy.mockReturnValueOnce("tag-gen-id").mockReturnValueOnce("tc-gen-id") - - await handleSyncChannelLabels(messengerJob()) - - expect(tagBuilder.values).toHaveBeenCalledWith( - expect.objectContaining({ id: "tag-gen-id" }), - ) - expect(tagChannelBuilder.values).toHaveBeenCalledWith( - expect.objectContaining({ id: "tc-gen-id" }), - ) - }) -}) diff --git a/apps/worker/__tests__/sync-tag-create-attach.test.ts b/apps/worker/__tests__/sync-tag-create-attach.test.ts index 52c17cfd03..daa99db473 100644 --- a/apps/worker/__tests__/sync-tag-create-attach.test.ts +++ b/apps/worker/__tests__/sync-tag-create-attach.test.ts @@ -1,19 +1,20 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ── Query spies ─────────────────────────────────────────────────────────────── -const findTagFirst = vi.fn() +// ── Repository / service spies ──────────────────────────────────────────────── +const findTag = vi.fn() const findManyMessengerIntegrations = vi.fn() const findManyZaloIntegrations = vi.fn() -const findTagChannelFirst = vi.fn() +const findTagChannelByTagAndIntegration = vi.fn() const findManyContactInboxes = vi.fn() -const findMessengerIntegrationFirst = vi.fn() -const findZaloIntegrationFirst = vi.fn() +const findMessengerIntegrationByInboxId = vi.fn() +const findZaloIntegrationByInboxId = vi.fn() // ── Mutation spies ──────────────────────────────────────────────────────────── -const insertValues = vi.fn() -const insertReturning = vi.fn() -const updateSet = vi.fn() -const updateWhere = vi.fn() +const tagChannelInsertIfAbsent = vi.fn() +const tagChannelUpdateExternalLabelId = vi.fn() +const tagChannelInsertOrFetch = vi.fn() +const tagChannelUpsertByTagAndIntegration = vi.fn() +const tagChannelLinkContactInbox = vi.fn() // ── Integration API spies ───────────────────────────────────────────────────── const messengerCreateLabel = vi.fn() @@ -21,69 +22,40 @@ const messengerAssignLabel = vi.fn() const zaloTagFollower = vi.fn() const zaloRunAction = vi.fn() -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - tagModel: { findFirst: (...args: unknown[]) => findTagFirst(...args) }, - integrationMessengerModel: { - findMany: (...args: unknown[]) => - findManyMessengerIntegrations(...args), - findFirst: (...args: unknown[]) => - findMessengerIntegrationFirst(...args), - }, - integrationZaloModel: { - findMany: (...args: unknown[]) => findManyZaloIntegrations(...args), - findFirst: (...args: unknown[]) => findZaloIntegrationFirst(...args), - }, - tagChannelModel: { - findFirst: (...args: unknown[]) => findTagChannelFirst(...args), - }, - contactInboxModel: { - findMany: (...args: unknown[]) => findManyContactInboxes(...args), - }, - }, - insert: () => ({ - values: (vals: unknown) => { - insertValues(vals) - return { - onConflictDoNothing: () => ({ - returning: () => insertReturning(), - }), - onConflictDoUpdate: () => ({ - returning: () => insertReturning(), - }), - } - }, - }), - update: () => ({ - set: (vals: unknown) => { - updateSet(vals) - return { - where: (cond: unknown) => { - updateWhere(cond) - return Promise.resolve() - }, - } - }, - }), +vi.mock("@chatbotx.io/business", () => ({ + buildContext: vi.fn().mockResolvedValue({ auth: {}, workspaceId: "ws-1" }), + tagService: { + findById: (...args: unknown[]) => findTag(...args), + }, + zaloIntegrationService: { + listByWorkspace: (...args: unknown[]) => findManyZaloIntegrations(...args), + findByInboxId: (...args: unknown[]) => + findZaloIntegrationByInboxId(...args), }, - and: (...args: unknown[]) => ({ and: args }), - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), - isNotNull: (col: unknown) => ({ isNotNull: col }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { __name: "Tag" }, - tagChannelModel: { __name: "TagChannel" }, - contactToTagChannelModel: { __name: "ContactToTagChannel" }, - contactInboxModel: { __name: "ContactInbox" }, - integrationMessengerModel: { __name: "IntegrationMessenger" }, - integrationZaloModel: { __name: "IntegrationZalo" }, })) -vi.mock("@chatbotx.io/business", () => ({ - buildContext: vi.fn().mockResolvedValue({ auth: {}, workspaceId: "ws-1" }), +vi.mock("@chatbotx.io/database/repositories", () => ({ + tagChannelRepository: { + insertIfAbsent: (...args: unknown[]) => tagChannelInsertIfAbsent(...args), + findByTagAndIntegration: (...args: unknown[]) => + findTagChannelByTagAndIntegration(...args), + updateExternalLabelId: (...args: unknown[]) => + tagChannelUpdateExternalLabelId(...args), + insertOrFetch: (...args: unknown[]) => tagChannelInsertOrFetch(...args), + upsertByTagAndIntegration: (...args: unknown[]) => + tagChannelUpsertByTagAndIntegration(...args), + linkContactInbox: (...args: unknown[]) => + tagChannelLinkContactInbox(...args), + }, + contactInboxRepository: { + listByContactId: (...args: unknown[]) => findManyContactInboxes(...args), + }, + integrationMessengerRepository: { + listByWorkspace: (...args: unknown[]) => + findManyMessengerIntegrations(...args), + findByInboxId: (...args: unknown[]) => + findMessengerIntegrationByInboxId(...args), + }, })) vi.mock("@chatbotx.io/integration-messenger", () => ({ @@ -117,17 +89,10 @@ vi.mock("@chatbotx.io/redis", () => ({ }, })) -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: () => "generated-id" } -}) - -vi.mock("@chatbotx.io/database/partials", async () => { - const actual = await vi.importActual< - typeof import("@chatbotx.io/database/partials") - >("@chatbotx.io/database/partials") - return actual -}) +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), + logProviderErrorForChannel: vi.fn(), +})) vi.mock("../src/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -178,17 +143,18 @@ const ZALO_CONTACT_INBOX = { } beforeEach(() => { - findTagFirst.mockReset() + findTag.mockReset() findManyMessengerIntegrations.mockReset() findManyZaloIntegrations.mockReset() - findTagChannelFirst.mockReset() + findTagChannelByTagAndIntegration.mockReset() findManyContactInboxes.mockReset() - findMessengerIntegrationFirst.mockReset() - findZaloIntegrationFirst.mockReset() - insertValues.mockReset() - insertReturning.mockReset() - updateSet.mockReset() - updateWhere.mockReset() + findMessengerIntegrationByInboxId.mockReset() + findZaloIntegrationByInboxId.mockReset() + tagChannelInsertIfAbsent.mockReset() + tagChannelUpdateExternalLabelId.mockReset() + tagChannelInsertOrFetch.mockReset() + tagChannelUpsertByTagAndIntegration.mockReset() + tagChannelLinkContactInbox.mockReset() messengerCreateLabel.mockReset() messengerAssignLabel.mockReset() zaloTagFollower.mockReset() @@ -199,7 +165,11 @@ beforeEach(() => { messengerCreateLabel.mockResolvedValue({ id: "fb-label-123", name: "VIP" }) messengerAssignLabel.mockResolvedValue(undefined) zaloTagFollower.mockResolvedValue(undefined) - insertReturning.mockResolvedValue([TAG_CHANNEL]) + tagChannelInsertIfAbsent.mockResolvedValue(undefined) + tagChannelUpdateExternalLabelId.mockResolvedValue(undefined) + tagChannelInsertOrFetch.mockResolvedValue(TAG_CHANNEL) + tagChannelUpsertByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) + tagChannelLinkContactInbox.mockResolvedValue(undefined) }) // ── syncTagCreate / createMessengerLabel ────────────────────────────────────── @@ -208,9 +178,9 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { handleSyncTag({ action: "create", workspaceId: WS, tagId: TAG_ID }) test("always calls createLabel API regardless of existing TagChannel", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([MESSENGER_INTEGRATION]) - findTagChannelFirst.mockResolvedValue(TAG_CHANNEL) + findTagChannelByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) await runCreate() @@ -218,27 +188,30 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { }) test("when TagChannel exists: updates externalLabelId, does NOT insert", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([MESSENGER_INTEGRATION]) - findTagChannelFirst.mockResolvedValue(TAG_CHANNEL) + findTagChannelByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) messengerCreateLabel.mockResolvedValue({ id: "new-fb-label", name: "VIP" }) await runCreate() - expect(updateSet).toHaveBeenCalledWith( - expect.objectContaining({ externalLabelId: "new-fb-label" }), + expect(tagChannelUpdateExternalLabelId).toHaveBeenCalledWith( + expect.objectContaining({ + id: TAG_CHANNEL.id, + externalLabelId: "new-fb-label", + }), ) - expect(insertValues).not.toHaveBeenCalled() + expect(tagChannelInsertIfAbsent).not.toHaveBeenCalled() }) test("when TagChannel does not exist: inserts new row, does NOT update", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([MESSENGER_INTEGRATION]) - findTagChannelFirst.mockResolvedValue(null) + findTagChannelByTagAndIntegration.mockResolvedValue(null) await runCreate() - expect(insertValues).toHaveBeenCalledWith( + expect(tagChannelInsertIfAbsent).toHaveBeenCalledWith( expect.objectContaining({ tagId: TAG_ID, externalLabelId: "fb-label-123", @@ -246,11 +219,11 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { integrationId: INTEGRATION_ID, }), ) - expect(updateSet).not.toHaveBeenCalled() + expect(tagChannelUpdateExternalLabelId).not.toHaveBeenCalled() }) test("skips integration with syncTagEnabledAt = null", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([ { ...MESSENGER_INTEGRATION, syncTagEnabledAt: null }, ]) @@ -261,7 +234,7 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { }) test("returns early when tag not found", async () => { - findTagFirst.mockResolvedValue(null) + findTag.mockResolvedValue(null) await runCreate() @@ -271,12 +244,12 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { test("continues to next integration when one throws", async () => { const INT_2 = { ...MESSENGER_INTEGRATION, id: "int-2" } - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([ MESSENGER_INTEGRATION, INT_2, ]) - findTagChannelFirst.mockResolvedValue(null) + findTagChannelByTagAndIntegration.mockResolvedValue(null) messengerCreateLabel .mockRejectedValueOnce(new Error("Facebook API error")) .mockResolvedValueOnce({ id: "fb-2", name: "VIP" }) @@ -288,12 +261,12 @@ describe("syncTagCreate — Messenger (createMessengerLabel)", () => { test("calls createLabel once per enabled integration", async () => { const INT_2 = { ...MESSENGER_INTEGRATION, id: "int-2" } - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyMessengerIntegrations.mockResolvedValue([ MESSENGER_INTEGRATION, INT_2, ]) - findTagChannelFirst.mockResolvedValue(null) + findTagChannelByTagAndIntegration.mockResolvedValue(null) await runCreate() @@ -307,12 +280,12 @@ describe("syncTagCreate — Zalo (no API, insert tagChannel mapping)", () => { handleSyncTag({ action: "create", workspaceId: WS, tagId: TAG_ID }) test("inserts tagChannelModel for Zalo using tag name as externalLabelId", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyZaloIntegrations.mockResolvedValue([ZALO_INTEGRATION]) await runCreate() - expect(insertValues).toHaveBeenCalledWith( + expect(tagChannelInsertIfAbsent).toHaveBeenCalledWith( expect.objectContaining({ tagId: TAG_ID, channelType: "zalo", @@ -323,7 +296,7 @@ describe("syncTagCreate — Zalo (no API, insert tagChannel mapping)", () => { }) test("no Zalo API call — just DB insert", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyZaloIntegrations.mockResolvedValue([ZALO_INTEGRATION]) await runCreate() @@ -333,24 +306,24 @@ describe("syncTagCreate — Zalo (no API, insert tagChannel mapping)", () => { }) test("skips Zalo integration with syncTagEnabledAt = null", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyZaloIntegrations.mockResolvedValue([ { ...ZALO_INTEGRATION, syncTagEnabledAt: null }, ]) await runCreate() - expect(insertValues).not.toHaveBeenCalled() + expect(tagChannelInsertIfAbsent).not.toHaveBeenCalled() }) test("inserts for each enabled Zalo integration", async () => { const ZALO_2 = { ...ZALO_INTEGRATION, id: "zalo-int-2" } - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyZaloIntegrations.mockResolvedValue([ZALO_INTEGRATION, ZALO_2]) await runCreate() - expect(insertValues).toHaveBeenCalledTimes(2) + expect(tagChannelInsertIfAbsent).toHaveBeenCalledTimes(2) }) }) @@ -365,10 +338,10 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { }) const setupMessengerAttach = () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([MESSENGER_CONTACT_INBOX]) - findMessengerIntegrationFirst.mockResolvedValue(MESSENGER_INTEGRATION) - findTagChannelFirst.mockResolvedValue(TAG_CHANNEL) + findMessengerIntegrationByInboxId.mockResolvedValue(MESSENGER_INTEGRATION) + findTagChannelByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) } test("does NOT call createLabel when TagChannel already exists", async () => { @@ -380,13 +353,11 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { }) test("calls createLabel when TagChannel does not exist", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([MESSENGER_CONTACT_INBOX]) - findMessengerIntegrationFirst.mockResolvedValue(MESSENGER_INTEGRATION) - findTagChannelFirst - .mockResolvedValueOnce(null) // lock: no existing → create - .mockResolvedValue(null) // fallback findFirst after conflict - insertReturning.mockResolvedValueOnce([TAG_CHANNEL]) // insert returns row + findMessengerIntegrationByInboxId.mockResolvedValue(MESSENGER_INTEGRATION) + findTagChannelByTagAndIntegration.mockResolvedValue(null) // lock: no existing → create + tagChannelInsertOrFetch.mockResolvedValueOnce(TAG_CHANNEL) // insert returns row await runAttach() @@ -409,26 +380,24 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { ) }) - test("inserts contactToTagChannel row after assignLabel", async () => { + test("links contactInbox to tagChannel after assignLabel", async () => { setupMessengerAttach() await runAttach() - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ - tagId: TAG_ID, - tagChannelId: TAG_CHANNEL.id, - contactInboxId: MESSENGER_CONTACT_INBOX.id, - }), - ) + expect(tagChannelLinkContactInbox).toHaveBeenCalledWith({ + tagId: TAG_ID, + tagChannelId: TAG_CHANNEL.id, + contactInboxId: MESSENGER_CONTACT_INBOX.id, + }) }) test("skips when tagChannel cannot be resolved (lock returns null)", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([MESSENGER_CONTACT_INBOX]) - findMessengerIntegrationFirst.mockResolvedValue(MESSENGER_INTEGRATION) - findTagChannelFirst.mockResolvedValue(null) - insertReturning.mockResolvedValue([]) // insert conflict, nothing returned + findMessengerIntegrationByInboxId.mockResolvedValue(MESSENGER_INTEGRATION) + findTagChannelByTagAndIntegration.mockResolvedValue(null) + tagChannelInsertOrFetch.mockResolvedValue(undefined) // insert conflict, nothing returned await runAttach() @@ -436,9 +405,9 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { }) test("skips when integration has syncTagEnabledAt = null", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([MESSENGER_CONTACT_INBOX]) - findMessengerIntegrationFirst.mockResolvedValue({ + findMessengerIntegrationByInboxId.mockResolvedValue({ ...MESSENGER_INTEGRATION, syncTagEnabledAt: null, }) @@ -449,7 +418,7 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { }) test("skips when tag not found", async () => { - findTagFirst.mockResolvedValue(null) + findTag.mockResolvedValue(null) await runAttach() @@ -457,7 +426,7 @@ describe("syncTagAttach — Messenger (attachOnMessenger)", () => { }) test("skips when no contact inboxes", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([]) await runAttach() @@ -486,10 +455,10 @@ describe("syncTagAttach — Zalo (attachOnZalo)", () => { } const setupZaloAttach = () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([ZALO_CONTACT_INBOX]) - findZaloIntegrationFirst.mockResolvedValue(ZALO_INTEGRATION) - insertReturning.mockResolvedValue([ZALO_TAG_CHANNEL]) + findZaloIntegrationByInboxId.mockResolvedValue(ZALO_INTEGRATION) + tagChannelUpsertByTagAndIntegration.mockResolvedValue(ZALO_TAG_CHANNEL) } test("calls tagFollower Zalo action", async () => { @@ -506,12 +475,12 @@ describe("syncTagAttach — Zalo (attachOnZalo)", () => { ) }) - test("upserts tagChannelModel with onConflictDoUpdate", async () => { + test("upserts tagChannelModel via upsertByTagAndIntegration", async () => { setupZaloAttach() await runAttach() - expect(insertValues).toHaveBeenCalledWith( + expect(tagChannelUpsertByTagAndIntegration).toHaveBeenCalledWith( expect.objectContaining({ tagId: TAG_ID, channelType: "zalo", @@ -521,35 +490,33 @@ describe("syncTagAttach — Zalo (attachOnZalo)", () => { ) }) - test("inserts contactToTagChannel row after upsert", async () => { + test("links contactInbox to tagChannel after upsert", async () => { setupZaloAttach() await runAttach() - // Second insert call is for contactToTagChannelModel - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ - tagId: TAG_ID, - tagChannelId: ZALO_TAG_CHANNEL.id, - contactInboxId: ZALO_CONTACT_INBOX.id, - }), - ) + expect(tagChannelLinkContactInbox).toHaveBeenCalledWith({ + tagId: TAG_ID, + tagChannelId: ZALO_TAG_CHANNEL.id, + contactInboxId: ZALO_CONTACT_INBOX.id, + }) }) - test("skips contactToTagChannel insert when tagChannel upsert returns nothing", async () => { + test("skips contactInbox link when tagChannel upsert returns nothing", async () => { setupZaloAttach() - insertReturning.mockResolvedValue([]) // upsert returns empty (unexpected) + tagChannelUpsertByTagAndIntegration.mockResolvedValue(undefined) // upsert returns empty (unexpected) await runAttach() - // Only the tagChannel insert fires; no contactToTagChannel insert - expect(insertValues).toHaveBeenCalledTimes(1) + // The upsert still fires; no link call + expect(tagChannelUpsertByTagAndIntegration).toHaveBeenCalledTimes(1) + expect(tagChannelLinkContactInbox).not.toHaveBeenCalled() }) test("skips when integration has syncTagEnabledAt = null", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([ZALO_CONTACT_INBOX]) - findZaloIntegrationFirst.mockResolvedValue({ + findZaloIntegrationByInboxId.mockResolvedValue({ ...ZALO_INTEGRATION, syncTagEnabledAt: null, }) @@ -560,15 +527,15 @@ describe("syncTagAttach — Zalo (attachOnZalo)", () => { }) test("routes messenger and zalo inboxes independently in the same attach", async () => { - findTagFirst.mockResolvedValue(TAG) + findTag.mockResolvedValue(TAG) findManyContactInboxes.mockResolvedValue([ MESSENGER_CONTACT_INBOX, ZALO_CONTACT_INBOX, ]) - findMessengerIntegrationFirst.mockResolvedValue(MESSENGER_INTEGRATION) - findTagChannelFirst.mockResolvedValue(TAG_CHANNEL) - findZaloIntegrationFirst.mockResolvedValue(ZALO_INTEGRATION) - insertReturning.mockResolvedValue([TAG_CHANNEL]) + findMessengerIntegrationByInboxId.mockResolvedValue(MESSENGER_INTEGRATION) + findTagChannelByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) + findZaloIntegrationByInboxId.mockResolvedValue(ZALO_INTEGRATION) + tagChannelUpsertByTagAndIntegration.mockResolvedValue(TAG_CHANNEL) await runAttach() diff --git a/apps/worker/__tests__/sync-tag-delete.test.ts b/apps/worker/__tests__/sync-tag-delete.test.ts index 28022cd64f..3d1d4990b3 100644 --- a/apps/worker/__tests__/sync-tag-delete.test.ts +++ b/apps/worker/__tests__/sync-tag-delete.test.ts @@ -1,74 +1,67 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ── db spies ────────────────────────────────────────────────────────────────── +// ── repository / service spies ──────────────────────────────────────────────── const findManyTagChannel = vi.fn() -const findManyContactToTagChannel = vi.fn() -const findManyContactsToTags = vi.fn() -const findManyContactInbox = vi.fn() -const findMessengerIntegrationFirst = vi.fn() -const findZaloIntegrationFirst = vi.fn() +const findMessengerIntegrationById = vi.fn() +const findZaloIntegrationUnscoped = vi.fn() -// Track delete calls in order so we can assert on sequence and model identity. -const dbDeleteCalls: Array<{ model: unknown; condition: unknown }> = [] +// Cleanup mutation spies +const tagChannelListContactInboxIdsForChannelPage = vi.fn() +const tagChannelDeleteLinksForChannel = vi.fn() +const tagChannelDeleteContactTagsForContacts = vi.fn() +const tagChannelDeleteById = vi.fn() +const tagChannelListTaggedContactIdsPage = vi.fn() +const contactInboxListContactIdsByIds = vi.fn() +const tagServiceHardDeleteSoftDeleted = vi.fn() + +// Track calls in order so we can assert on sequence. +const callLog: string[] = [] // ── channel API spies ───────────────────────────────────────────────────────── const messengerDeleteLabel = vi.fn() const zaloRemoveTag = vi.fn() -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - tagChannelModel: { - findMany: (...args: unknown[]) => findManyTagChannel(...args), - }, - contactToTagChannelModel: { - findMany: (...args: unknown[]) => findManyContactToTagChannel(...args), - }, - contactsToTagsModel: { - findMany: (...args: unknown[]) => findManyContactsToTags(...args), - }, - contactInboxModel: { - findMany: (...args: unknown[]) => findManyContactInbox(...args), - }, - integrationMessengerModel: { - findFirst: (...args: unknown[]) => - findMessengerIntegrationFirst(...args), - }, - integrationZaloModel: { - findFirst: (...args: unknown[]) => findZaloIntegrationFirst(...args), - }, +vi.mock("@chatbotx.io/database/repositories", () => ({ + tagChannelRepository: { + listByTag: (...args: unknown[]) => findManyTagChannel(...args), + listContactInboxIdsForChannelPage: (...args: unknown[]) => + tagChannelListContactInboxIdsForChannelPage(...args), + deleteLinksForChannel: (...args: unknown[]) => { + callLog.push("deleteLinksForChannel") + return tagChannelDeleteLinksForChannel(...args) + }, + deleteContactTagsForContacts: (...args: unknown[]) => { + callLog.push("deleteContactTagsForContacts") + return tagChannelDeleteContactTagsForContacts(...args) + }, + deleteById: (...args: unknown[]) => { + callLog.push("deleteById") + return tagChannelDeleteById(...args) }, - delete: (model: unknown) => ({ - where: (cond: unknown) => { - dbDeleteCalls.push({ model, condition: cond }) - return Promise.resolve() - }, - }), + listTaggedContactIdsPage: (...args: unknown[]) => + tagChannelListTaggedContactIdsPage(...args), + }, + contactInboxRepository: { + listContactIdsByIds: (...args: unknown[]) => + contactInboxListContactIdsByIds(...args), + }, + integrationMessengerRepository: { + findById: (...args: unknown[]) => findMessengerIntegrationById(...args), }, - and: (...args: unknown[]) => ({ and: args }), - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), - isNotNull: (col: unknown) => ({ isNotNull: col }), })) -// Inline sentinel objects — avoids vi.mock hoisting / variable-capture issues. -vi.mock("@chatbotx.io/database/schema", () => ({ - contactToTagChannelModel: { - __name: "ContactToTagChannel", - tagId: "ContactToTagChannel.tagId", - tagChannelId: "ContactToTagChannel.tagChannelId", - contactInboxId: "ContactToTagChannel.contactInboxId", +vi.mock("@chatbotx.io/business", () => ({ + buildContext: vi.fn().mockResolvedValue({ auth: {}, workspaceId: "ws-1" }), + tagService: { + hardDeleteSoftDeleted: (...args: unknown[]) => { + callLog.push("hardDeleteSoftDeleted") + return tagServiceHardDeleteSoftDeleted(...args) + }, }, - tagChannelModel: { __name: "TagChannel", id: "TagChannel.id" }, - contactsToTagsModel: { - __name: "ContactsToTags", - tagId: "ContactsToTags.tagId", - contactId: "ContactsToTags.contactId", + zaloIntegrationService: { + findByIdUnscoped: (...args: unknown[]) => + findZaloIntegrationUnscoped(...args), }, - tagModel: { __name: "Tag", id: "Tag.id", deletedAt: "Tag.deletedAt" }, - contactInboxModel: { __name: "ContactInbox" }, - integrationMessengerModel: { __name: "IntegrationMessenger" }, - integrationZaloModel: { __name: "IntegrationZalo" }, })) vi.mock("@chatbotx.io/database/utils", () => ({ @@ -85,10 +78,6 @@ vi.mock("@chatbotx.io/database/utils", () => ({ }, })) -vi.mock("@chatbotx.io/business", () => ({ - buildContext: vi.fn().mockResolvedValue({ auth: {}, workspaceId: "ws-1" }), -})) - vi.mock("@chatbotx.io/integration-messenger", () => ({ integration: { runChannelHandler: (_group: unknown, name: unknown, ...args: unknown[]) => { @@ -112,20 +101,15 @@ vi.mock("@chatbotx.io/integration-zalo", () => ({ })) vi.mock("@chatbotx.io/redis", () => ({ - distributedLock: vi.fn((_key: unknown, fn: () => Promise) => fn()), + distributedLock: { + runExclusive: vi.fn(({ fn }: { fn: () => Promise }) => fn()), + }, })) -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: () => "generated-id" } -}) - -vi.mock("@chatbotx.io/database/partials", async () => { - const actual = await vi.importActual< - typeof import("@chatbotx.io/database/partials") - >("@chatbotx.io/database/partials") - return actual -}) +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), + logProviderErrorForChannel: vi.fn(), +})) vi.mock("../src/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -171,26 +155,31 @@ const runScopedDelete = (channelType: string, integrationId: string) => integrationId, }) -const modelName = (m: unknown) => (m as { __name: string }).__name -const deletedModelNames = () => dbDeleteCalls.map((c) => modelName(c.model)) - beforeEach(() => { findManyTagChannel.mockReset() - findManyContactToTagChannel.mockReset() - findManyContactsToTags.mockReset() - findManyContactInbox.mockReset() - findMessengerIntegrationFirst.mockReset() - findZaloIntegrationFirst.mockReset() + findMessengerIntegrationById.mockReset() + findZaloIntegrationUnscoped.mockReset() + tagChannelListContactInboxIdsForChannelPage.mockReset() + tagChannelDeleteLinksForChannel.mockReset() + tagChannelDeleteContactTagsForContacts.mockReset() + tagChannelDeleteById.mockReset() + tagChannelListTaggedContactIdsPage.mockReset() + contactInboxListContactIdsByIds.mockReset() + tagServiceHardDeleteSoftDeleted.mockReset() messengerDeleteLabel.mockReset() zaloRemoveTag.mockReset() - dbDeleteCalls.length = 0 + callLog.length = 0 findManyTagChannel.mockResolvedValue([]) - findManyContactToTagChannel.mockResolvedValue([]) - findManyContactsToTags.mockResolvedValue([]) - findManyContactInbox.mockResolvedValue([]) - findMessengerIntegrationFirst.mockResolvedValue(ENABLED_MESSENGER) - findZaloIntegrationFirst.mockResolvedValue(ENABLED_ZALO) + tagChannelListContactInboxIdsForChannelPage.mockResolvedValue([]) + tagChannelDeleteLinksForChannel.mockResolvedValue(undefined) + tagChannelDeleteContactTagsForContacts.mockResolvedValue(undefined) + tagChannelDeleteById.mockResolvedValue(undefined) + tagChannelListTaggedContactIdsPage.mockResolvedValue([]) + contactInboxListContactIdsByIds.mockResolvedValue([]) + tagServiceHardDeleteSoftDeleted.mockResolvedValue(undefined) + findMessengerIntegrationById.mockResolvedValue(ENABLED_MESSENGER) + findZaloIntegrationUnscoped.mockResolvedValue(ENABLED_ZALO) messengerDeleteLabel.mockResolvedValue(undefined) zaloRemoveTag.mockResolvedValue(undefined) }) @@ -207,56 +196,74 @@ describe("syncTagDelete — full workspace delete", () => { expect(messengerDeleteLabel).not.toHaveBeenCalled() expect(zaloRemoveTag).not.toHaveBeenCalled() // cleanup + Tag delete still happen - expect(deletedModelNames().at(-1)).toBe("Tag") + expect(callLog.at(-1)).toBe("hardDeleteSoftDeleted") }) - test("hard-deletes the Tag row LAST, with the isNotNull(deletedAt) guard", async () => { + test("hard-deletes the Tag row LAST, via tagService.hardDeleteSoftDeleted", async () => { findManyTagChannel.mockResolvedValue([MESSENGER_CHANNEL]) await runDelete() - const last = dbDeleteCalls.at(-1) - expect(modelName(last?.model)).toBe("Tag") - expect(JSON.stringify(last?.condition)).toContain("isNotNull") + expect(callLog.at(-1)).toBe("hardDeleteSoftDeleted") + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalledWith({ + workspaceId: WS, + tagId: TAG_ID, + }) }) - test("per channel: deletes ContactToTagChannel + ContactsToTags + TagChannel, then Tag", async () => { + test("per channel: deletes ContactToTagChannel links + ContactsToTags + TagChannel, then Tag", async () => { findManyTagChannel.mockResolvedValue([MESSENGER_CHANNEL]) - findManyContactToTagChannel.mockResolvedValue([{ contactInboxId: "ci-1" }]) - findManyContactInbox.mockResolvedValue([{ contactId: "c-1" }]) + tagChannelListContactInboxIdsForChannelPage.mockResolvedValue([ + { contactInboxId: "ci-1" }, + ]) + contactInboxListContactIdsByIds.mockResolvedValue([{ contactId: "c-1" }]) await runDelete() - const names = deletedModelNames() - expect(names).toContain("ContactToTagChannel") - expect(names).toContain("ContactsToTags") - expect(names).toContain("TagChannel") - expect(names.at(-1)).toBe("Tag") + expect(callLog).toContain("deleteLinksForChannel") + expect(callLog).toContain("deleteContactTagsForContacts") + expect(callLog).toContain("deleteById") + expect(callLog.at(-1)).toBe("hardDeleteSoftDeleted") + expect(tagChannelDeleteLinksForChannel).toHaveBeenCalledWith({ + tagChannelId: MESSENGER_CHANNEL.id, + contactInboxIds: ["ci-1"], + }) + expect(tagChannelDeleteContactTagsForContacts).toHaveBeenCalledWith({ + tagId: TAG_ID, + contactIds: ["c-1"], + }) + expect(tagChannelDeleteById).toHaveBeenCalledWith({ + id: MESSENGER_CHANNEL.id, + }) }) test("catch-all removes manually-applied ContactToTag (no channel mapping)", async () => { findManyTagChannel.mockResolvedValue([]) // tag never synced to a channel - findManyContactsToTags.mockResolvedValue([{ contactId: "c-manual" }]) + tagChannelListTaggedContactIdsPage.mockResolvedValue([ + { contactId: "c-manual" }, + ]) await runDelete() - const names = deletedModelNames() - expect(names).toContain("ContactsToTags") - expect(names.at(-1)).toBe("Tag") + expect(tagChannelDeleteContactTagsForContacts).toHaveBeenCalledWith({ + tagId: TAG_ID, + contactIds: ["c-manual"], + }) + expect(callLog.at(-1)).toBe("hardDeleteSoftDeleted") }) test("no channels, no manual links → only the Tag row is deleted", async () => { findManyTagChannel.mockResolvedValue([]) - findManyContactsToTags.mockResolvedValue([]) + tagChannelListTaggedContactIdsPage.mockResolvedValue([]) await runDelete() - expect(deletedModelNames()).toEqual(["Tag"]) + expect(callLog).toEqual(["hardDeleteSoftDeleted"]) }) test("deletes the Tag regardless of integration sync state (API disabled)", async () => { findManyTagChannel.mockResolvedValue([MESSENGER_CHANNEL]) - findMessengerIntegrationFirst.mockResolvedValue({ + findMessengerIntegrationById.mockResolvedValue({ ...ENABLED_MESSENGER, syncTagEnabledAt: null, }) @@ -264,7 +271,7 @@ describe("syncTagDelete — full workspace delete", () => { await runDelete() expect(messengerDeleteLabel).not.toHaveBeenCalled() - expect(deletedModelNames().at(-1)).toBe("Tag") + expect(callLog.at(-1)).toBe("hardDeleteSoftDeleted") }) }) @@ -274,8 +281,10 @@ describe("syncTagDelete — full workspace delete", () => { describe("syncTagDelete — channel-scoped (webhook)", () => { test("does NOT call the channel API and does NOT delete the Tag row", async () => { findManyTagChannel.mockResolvedValue([ZALO_CHANNEL]) - findManyContactToTagChannel.mockResolvedValue([{ contactInboxId: "ci-1" }]) - findManyContactInbox.mockResolvedValue([{ contactId: "c-1" }]) + tagChannelListContactInboxIdsForChannelPage.mockResolvedValue([ + { contactInboxId: "ci-1" }, + ]) + contactInboxListContactIdsByIds.mockResolvedValue([{ contactId: "c-1" }]) await runScopedDelete("zalo", "zalo-int-1") @@ -283,12 +292,12 @@ describe("syncTagDelete — channel-scoped (webhook)", () => { expect(zaloRemoveTag).not.toHaveBeenCalled() expect(messengerDeleteLabel).not.toHaveBeenCalled() - const names = deletedModelNames() - expect(names).toContain("ContactToTagChannel") - expect(names).toContain("ContactsToTags") - expect(names).toContain("TagChannel") + expect(callLog).toContain("deleteLinksForChannel") + expect(callLog).toContain("deleteContactTagsForContacts") + expect(callLog).toContain("deleteById") // Tag row is kept. - expect(names).not.toContain("Tag") + expect(callLog).not.toContain("hardDeleteSoftDeleted") + expect(tagServiceHardDeleteSoftDeleted).not.toHaveBeenCalled() }) test("no-op when the tag is not mapped on that channel", async () => { @@ -296,6 +305,6 @@ describe("syncTagDelete — channel-scoped (webhook)", () => { await runScopedDelete("zalo", "zalo-int-1") - expect(dbDeleteCalls).toHaveLength(0) + expect(callLog).toHaveLength(0) }) }) diff --git a/apps/worker/__tests__/sync-tag-detach.test.ts b/apps/worker/__tests__/sync-tag-detach.test.ts index 1ea439a9dc..1b6ac72551 100644 --- a/apps/worker/__tests__/sync-tag-detach.test.ts +++ b/apps/worker/__tests__/sync-tag-detach.test.ts @@ -1,80 +1,35 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -// ── select chain spy ────────────────────────────────────────────────────────── -// syncTagDetach builds: db.select(...).from(...).innerJoin(...).innerJoin(...).where(...) -const selectWhere = vi.fn() - -// ── delete spy ──────────────────────────────────────────────────────────────── -const dbDeleteCalls: Array<{ model: unknown; condition: unknown }> = [] +// ── repository / service spies ──────────────────────────────────────────────── +// syncTagDetach resolves mapping rows via tagChannelRepository.listContactTagChannelRows +const listContactTagChannelRows = vi.fn() +const unlinkContactInbox = vi.fn() // ── integration context resolution ──────────────────────────────────────────── -const findMessengerIntegrationFirst = vi.fn() -const findZaloIntegrationFirst = vi.fn() +const findMessengerIntegrationById = vi.fn() +const findZaloIntegrationUnscoped = vi.fn() // ── channel API spies ───────────────────────────────────────────────────────── const messengerRemoveLabel = vi.fn() const zaloRemoveFollower = vi.fn() -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - integrationMessengerModel: { - findFirst: (...args: unknown[]) => - findMessengerIntegrationFirst(...args), - }, - integrationZaloModel: { - findFirst: (...args: unknown[]) => findZaloIntegrationFirst(...args), - }, - }, - select: () => ({ - from: () => ({ - innerJoin: () => ({ - innerJoin: () => ({ - where: (cond: unknown) => selectWhere(cond), - }), - }), - }), - }), - delete: (model: unknown) => ({ - where: (cond: unknown) => { - dbDeleteCalls.push({ model, condition: cond }) - return Promise.resolve() - }, - }), - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), - isNotNull: (col: unknown) => ({ isNotNull: col }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { __name: "Tag" }, - tagChannelModel: { - __name: "TagChannel", - id: "TagChannel.id", - channelType: "TagChannel.channelType", - integrationId: "TagChannel.integrationId", - externalLabelId: "TagChannel.externalLabelId", +vi.mock("@chatbotx.io/database/repositories", () => ({ + tagChannelRepository: { + listContactTagChannelRows: (...args: unknown[]) => + listContactTagChannelRows(...args), + unlinkContactInbox: (...args: unknown[]) => unlinkContactInbox(...args), }, - contactToTagChannelModel: { - __name: "ContactToTagChannel", - tagId: "ContactToTagChannel.tagId", - tagChannelId: "ContactToTagChannel.tagChannelId", - contactInboxId: "ContactToTagChannel.contactInboxId", + integrationMessengerRepository: { + findById: (...args: unknown[]) => findMessengerIntegrationById(...args), }, - contactInboxModel: { - __name: "ContactInbox", - id: "ContactInbox.id", - contactId: "ContactInbox.contactId", - sourceId: "ContactInbox.sourceId", - }, - integrationMessengerModel: { __name: "IntegrationMessenger" }, - integrationZaloModel: { __name: "IntegrationZalo" }, })) vi.mock("@chatbotx.io/business", () => ({ buildContext: vi.fn().mockResolvedValue({ auth: {}, workspaceId: "ws-1" }), + zaloIntegrationService: { + findByIdUnscoped: (...args: unknown[]) => + findZaloIntegrationUnscoped(...args), + }, })) vi.mock("@chatbotx.io/integration-messenger", () => ({ @@ -105,17 +60,10 @@ vi.mock("@chatbotx.io/redis", () => ({ }, })) -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: () => "generated-id" } -}) - -vi.mock("@chatbotx.io/database/partials", async () => { - const actual = await vi.importActual< - typeof import("@chatbotx.io/database/partials") - >("@chatbotx.io/database/partials") - return actual -}) +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), + logProviderErrorForChannel: vi.fn(), +})) vi.mock("../src/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -164,33 +112,34 @@ const runDetach = () => }) beforeEach(() => { - selectWhere.mockReset() - findMessengerIntegrationFirst.mockReset() - findZaloIntegrationFirst.mockReset() + listContactTagChannelRows.mockReset() + unlinkContactInbox.mockReset() + findMessengerIntegrationById.mockReset() + findZaloIntegrationUnscoped.mockReset() messengerRemoveLabel.mockReset() zaloRemoveFollower.mockReset() - dbDeleteCalls.length = 0 - selectWhere.mockResolvedValue([]) - findMessengerIntegrationFirst.mockResolvedValue(ENABLED_MESSENGER) - findZaloIntegrationFirst.mockResolvedValue(ENABLED_ZALO) + listContactTagChannelRows.mockResolvedValue([]) + unlinkContactInbox.mockResolvedValue(undefined) + findMessengerIntegrationById.mockResolvedValue(ENABLED_MESSENGER) + findZaloIntegrationUnscoped.mockResolvedValue(ENABLED_ZALO) messengerRemoveLabel.mockResolvedValue(undefined) zaloRemoveFollower.mockResolvedValue(undefined) }) describe("syncTagDetach", () => { - test("no mapping rows: no API calls, no deletes", async () => { - selectWhere.mockResolvedValue([]) + test("no mapping rows: no API calls, no unlinks", async () => { + listContactTagChannelRows.mockResolvedValue([]) await runDetach() expect(messengerRemoveLabel).not.toHaveBeenCalled() expect(zaloRemoveFollower).not.toHaveBeenCalled() - expect(dbDeleteCalls).toHaveLength(0) + expect(unlinkContactInbox).not.toHaveBeenCalled() }) - test("messenger row: calls removeLabel then deletes mapping", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW]) + test("messenger row: calls removeLabel then unlinks mapping", async () => { + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW]) await runDetach() @@ -203,11 +152,11 @@ describe("syncTagDetach", () => { }), }), ) - expect(dbDeleteCalls).toHaveLength(1) + expect(unlinkContactInbox).toHaveBeenCalledTimes(1) }) - test("zalo row: calls removeFollowerFromTag then deletes mapping", async () => { - selectWhere.mockResolvedValue([ZALO_ROW]) + test("zalo row: calls removeFollowerFromTag then unlinks mapping", async () => { + listContactTagChannelRows.mockResolvedValue([ZALO_ROW]) await runDetach() @@ -218,22 +167,22 @@ describe("syncTagDetach", () => { tagName: ZALO_ROW.externalLabelId, }), ) - expect(dbDeleteCalls).toHaveLength(1) + expect(unlinkContactInbox).toHaveBeenCalledTimes(1) }) - test("deletes local mapping even when channel API throws", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW]) + test("unlinks local mapping even when channel API throws", async () => { + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW]) messengerRemoveLabel.mockRejectedValue(new Error("Facebook API down")) await runDetach() - // API failed but the local mapping is still deleted - expect(dbDeleteCalls).toHaveLength(1) + // API failed but the local mapping is still unlinked + expect(unlinkContactInbox).toHaveBeenCalledTimes(1) }) - test("skips messenger API when integration sync disabled, still deletes mapping", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW]) - findMessengerIntegrationFirst.mockResolvedValue({ + test("skips messenger API when integration sync disabled, still unlinks mapping", async () => { + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW]) + findMessengerIntegrationById.mockResolvedValue({ ...ENABLED_MESSENGER, syncTagEnabledAt: null, }) @@ -241,12 +190,12 @@ describe("syncTagDetach", () => { await runDetach() expect(messengerRemoveLabel).not.toHaveBeenCalled() - expect(dbDeleteCalls).toHaveLength(1) + expect(unlinkContactInbox).toHaveBeenCalledTimes(1) }) - test("skips zalo API when integration sync disabled, still deletes mapping", async () => { - selectWhere.mockResolvedValue([ZALO_ROW]) - findZaloIntegrationFirst.mockResolvedValue({ + test("skips zalo API when integration sync disabled, still unlinks mapping", async () => { + listContactTagChannelRows.mockResolvedValue([ZALO_ROW]) + findZaloIntegrationUnscoped.mockResolvedValue({ ...ENABLED_ZALO, syncTagEnabledAt: null, }) @@ -254,37 +203,38 @@ describe("syncTagDetach", () => { await runDetach() expect(zaloRemoveFollower).not.toHaveBeenCalled() - expect(dbDeleteCalls).toHaveLength(1) + expect(unlinkContactInbox).toHaveBeenCalledTimes(1) }) - test("processes multiple rows: one delete per row", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW, ZALO_ROW]) + test("processes multiple rows: one unlink per row", async () => { + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW, ZALO_ROW]) await runDetach() expect(messengerRemoveLabel).toHaveBeenCalledTimes(1) expect(zaloRemoveFollower).toHaveBeenCalledTimes(1) - expect(dbDeleteCalls).toHaveLength(2) + expect(unlinkContactInbox).toHaveBeenCalledTimes(2) }) - test("delete is scoped by tagChannelId AND contactInboxId", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW]) + test("unlink is scoped by tagChannelId AND contactInboxId", async () => { + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW]) await runDetach() - const condStr = JSON.stringify(dbDeleteCalls[0]?.condition) - expect(condStr).toContain("tc-1") - expect(condStr).toContain("ci-1") + expect(unlinkContactInbox).toHaveBeenCalledWith({ + tagChannelId: MESSENGER_ROW.tagChannelId, + contactInboxId: MESSENGER_ROW.contactInboxId, + }) }) test("continues to second row when first row API throws", async () => { - selectWhere.mockResolvedValue([MESSENGER_ROW, ZALO_ROW]) + listContactTagChannelRows.mockResolvedValue([MESSENGER_ROW, ZALO_ROW]) messengerRemoveLabel.mockRejectedValue(new Error("boom")) await runDetach() - // Both rows still deleted; zalo API still called + // Both rows still unlinked; zalo API still called expect(zaloRemoveFollower).toHaveBeenCalledTimes(1) - expect(dbDeleteCalls).toHaveLength(2) + expect(unlinkContactInbox).toHaveBeenCalledTimes(2) }) }) diff --git a/apps/worker/__tests__/sync-tag.test.ts b/apps/worker/__tests__/sync-tag.test.ts index c013bdf101..f2a74cb342 100644 --- a/apps/worker/__tests__/sync-tag.test.ts +++ b/apps/worker/__tests__/sync-tag.test.ts @@ -3,137 +3,143 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const UNKNOWN_ACTION_RE = /unknown action/ // --------------------------------------------------------------------------- -// DB mock — chainable builder pattern (see contact-analytics.service.test.ts) +// Repository / service mocks — the handler now calls repository + business +// service methods instead of `db.*` directly. // --------------------------------------------------------------------------- -type ChainBuilder = Record - -// Shared result holders mutated by individual tests const queryResults = { - tagModelFindFirst: null as unknown, - integrationMessengerFindMany: [] as unknown[], - integrationZaloFindMany: [] as unknown[], - tagChannelFindFirst: null as unknown, - tagChannelFindMany: [] as unknown[], - integrationMessengerFindFirst: null as unknown, - integrationZaloFindFirst: null as unknown, - contactInboxFindMany: [] as unknown[], - contactToTagChannelFindMany: [] as unknown[], - contactsToTagsFindMany: [] as unknown[], - selectRows: [] as unknown[], + tag: null as unknown, + messengerIntegrations: [] as unknown[], + zaloIntegrations: [] as unknown[], + tagChannel: null as unknown, + tagChannelList: [] as unknown[], + messengerIntegration: null as unknown, + zaloIntegration: null as unknown, + contactInboxes: [] as unknown[], + contactTagChannelRows: [] as unknown[], + contactInboxIdsForChannelPage: [] as unknown[], + contactIdsByIds: [] as unknown[], + taggedContactIdsPage: [] as unknown[], } -const insertReturning = { current: [] as unknown[] } -const updateReturning = { current: [] as unknown[] } - -// Generic chainable builder factory -function makeChain(terminalFn?: () => Promise): ChainBuilder { - const builder: ChainBuilder = {} - const noop = () => builder - builder.set = vi.fn(noop) - builder.where = vi.fn(noop) - builder.returning = vi.fn(async () => insertReturning.current) - builder.onConflictDoNothing = vi.fn(noop) - builder.onConflictDoUpdate = vi.fn(noop) - builder.values = vi.fn(noop) - builder.innerJoin = vi.fn(noop) - builder.from = vi.fn(noop) - // terminal - if (terminalFn) { - // biome-ignore lint/suspicious/noThenProperty: intentional thenable for await support in tests - builder.then = vi.fn((resolve: (v: unknown) => unknown) => - Promise.resolve(terminalFn()).then(resolve), - ) - } - return builder -} +const tagServiceFindById = vi.fn(async () => queryResults.tag) +const tagServiceHardDeleteSoftDeleted = vi.fn(async () => undefined) -const insertChain = makeChain() -const updateChain = makeChain(async () => updateReturning.current) -const deleteChain = makeChain() -const selectChain = makeChain(async () => queryResults.selectRows) - -// Make insert().values().onConflictDoNothing().returning() work -insertChain.values = vi.fn(() => insertChain) -insertChain.onConflictDoNothing = vi.fn(() => insertChain) -insertChain.onConflictDoUpdate = vi.fn(() => insertChain) -insertChain.returning = vi.fn(async () => insertReturning.current) - -// Make update().set().where() chain -updateChain.set = vi.fn(() => updateChain) -updateChain.where = vi.fn(() => updateChain) - -// Make delete().where() chain -deleteChain.where = vi.fn(() => deleteChain) -// No returning needed for delete - -// Make select().from().innerJoin().where() chain -selectChain.from = vi.fn(() => selectChain) -selectChain.innerJoin = vi.fn(() => selectChain) -selectChain.where = vi.fn(async () => queryResults.selectRows) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - tagModel: { - findFirst: vi.fn(async () => queryResults.tagModelFindFirst), - }, - tagChannelModel: { - findFirst: vi.fn(async () => queryResults.tagChannelFindFirst), - findMany: vi.fn(async () => queryResults.tagChannelFindMany), - }, - integrationMessengerModel: { - findFirst: vi.fn( - async () => queryResults.integrationMessengerFindFirst, - ), - findMany: vi.fn(async () => queryResults.integrationMessengerFindMany), - }, - integrationZaloModel: { - findFirst: vi.fn(async () => queryResults.integrationZaloFindFirst), - findMany: vi.fn(async () => queryResults.integrationZaloFindMany), - }, - contactInboxModel: { - findMany: vi.fn(async () => queryResults.contactInboxFindMany), - }, - contactToTagChannelModel: { - findMany: vi.fn(async () => queryResults.contactToTagChannelFindMany), - }, - contactsToTagsModel: { - findMany: vi.fn(async () => queryResults.contactsToTagsFindMany), - }, - }, - insert: vi.fn(() => insertChain), - update: vi.fn(() => updateChain), - delete: vi.fn(() => deleteChain), - select: vi.fn(() => selectChain), +vi.mock("@chatbotx.io/business", () => ({ + buildContext: vi.fn(async () => fakeCtx), + tagService: { + findById: (...args: unknown[]) => tagServiceFindById(...args), + hardDeleteSoftDeleted: (...args: unknown[]) => + tagServiceHardDeleteSoftDeleted(...args), + }, + zaloIntegrationService: { + listByWorkspace: (...args: unknown[]) => zaloListByWorkspace(...args), + findByInboxId: (...args: unknown[]) => zaloFindByInboxId(...args), + findByIdUnscoped: (...args: unknown[]) => zaloFindByIdUnscoped(...args), }, - and: (...args: unknown[]) => args, - eq: (...args: unknown[]) => args, - inArray: (...args: unknown[]) => args, - isNotNull: (...args: unknown[]) => args, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - tagModel: { id: "id", workspaceId: "workspaceId", deletedAt: "deletedAt" }, - contactsToTagsModel: { - contactId: "contactId", - tagId: "tagId", +const zaloListByWorkspace = vi.fn(async () => queryResults.zaloIntegrations) +const zaloFindByInboxId = vi.fn(async () => queryResults.zaloIntegration) +const zaloFindByIdUnscoped = vi.fn(async () => queryResults.zaloIntegration) + +const tagChannelInsertIfAbsent = vi.fn(async () => undefined) +const tagChannelFindByTagAndIntegration = vi.fn( + async () => queryResults.tagChannel, +) +const tagChannelUpdateExternalLabelId = vi.fn(async () => undefined) +const tagChannelInsertOrFetch = vi.fn(async () => queryResults.tagChannel) +const tagChannelUpsertByTagAndIntegration = vi.fn( + async () => queryResults.tagChannel, +) +const tagChannelLinkContactInbox = vi.fn(async () => undefined) +const tagChannelUnlinkContactInbox = vi.fn(async () => undefined) +const tagChannelListContactTagChannelRows = vi.fn( + async () => queryResults.contactTagChannelRows, +) +const tagChannelListByTag = vi.fn(async () => queryResults.tagChannelList) +const tagChannelDeleteById = vi.fn(async () => undefined) +const tagChannelListContactInboxIdsForChannelPage = vi.fn( + async () => queryResults.contactInboxIdsForChannelPage, +) +const tagChannelDeleteLinksForChannel = vi.fn(async () => undefined) +const tagChannelDeleteContactTagsForContacts = vi.fn(async () => undefined) +const tagChannelListTaggedContactIdsPage = vi.fn( + async () => queryResults.taggedContactIdsPage, +) + +const contactInboxListByContactId = vi.fn( + async () => queryResults.contactInboxes, +) +const contactInboxListContactIdsByIds = vi.fn( + async () => queryResults.contactIdsByIds, +) + +const integrationMessengerListByWorkspace = vi.fn( + async () => queryResults.messengerIntegrations, +) +const integrationMessengerFindByInboxId = vi.fn( + async () => queryResults.messengerIntegration, +) +const integrationMessengerFindById = vi.fn( + async () => queryResults.messengerIntegration, +) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + tagChannelRepository: { + insertIfAbsent: (...args: unknown[]) => tagChannelInsertIfAbsent(...args), + findByTagAndIntegration: (...args: unknown[]) => + tagChannelFindByTagAndIntegration(...args), + updateExternalLabelId: (...args: unknown[]) => + tagChannelUpdateExternalLabelId(...args), + insertOrFetch: (...args: unknown[]) => tagChannelInsertOrFetch(...args), + upsertByTagAndIntegration: (...args: unknown[]) => + tagChannelUpsertByTagAndIntegration(...args), + linkContactInbox: (...args: unknown[]) => + tagChannelLinkContactInbox(...args), + unlinkContactInbox: (...args: unknown[]) => + tagChannelUnlinkContactInbox(...args), + listContactTagChannelRows: (...args: unknown[]) => + tagChannelListContactTagChannelRows(...args), + listByTag: (...args: unknown[]) => tagChannelListByTag(...args), + deleteById: (...args: unknown[]) => tagChannelDeleteById(...args), + listContactInboxIdsForChannelPage: (...args: unknown[]) => + tagChannelListContactInboxIdsForChannelPage(...args), + deleteLinksForChannel: (...args: unknown[]) => + tagChannelDeleteLinksForChannel(...args), + deleteContactTagsForContacts: (...args: unknown[]) => + tagChannelDeleteContactTagsForContacts(...args), + listTaggedContactIdsPage: (...args: unknown[]) => + tagChannelListTaggedContactIdsPage(...args), + }, + contactInboxRepository: { + listByContactId: (...args: unknown[]) => + contactInboxListByContactId(...args), + listContactIdsByIds: (...args: unknown[]) => + contactInboxListContactIdsByIds(...args), }, - tagChannelModel: { - id: "id", - tagId: "tagId", - channelType: "channelType", - integrationId: "integrationId", - workspaceId: "workspaceId", + integrationMessengerRepository: { + listByWorkspace: (...args: unknown[]) => + integrationMessengerListByWorkspace(...args), + findByInboxId: (...args: unknown[]) => + integrationMessengerFindByInboxId(...args), + findById: (...args: unknown[]) => integrationMessengerFindById(...args), }, - contactToTagChannelModel: { - tagChannelId: "tagChannelId", - contactInboxId: "contactInboxId", - tagId: "tagId", +})) + +// `chunkById` — single-chunk default: run the query once, invoke the +// callback if there are rows, then stop (mirrors the shared repo test +// helper pattern used elsewhere in this suite). +vi.mock("@chatbotx.io/database/utils", () => ({ + chunkById: async ( + queryBuilder: (lastId: string | null) => Promise<{ id: string }[]>, + options: { callback: (rows: { id: string }[]) => Promise }, + ) => { + const rows = await queryBuilder(null) + if (rows.length > 0) { + await options.callback(rows) + } }, - contactInboxModel: { id: "id", contactId: "contactId" }, - integrationMessengerModel: { id: "id" }, - integrationZaloModel: { id: "id" }, })) // --------------------------------------------------------------------------- @@ -150,6 +156,18 @@ vi.mock("@chatbotx.io/integration-zalo", () => ({ integration: { runAction: zaloRunAction }, })) +// --------------------------------------------------------------------------- +// error-log — spy only, never throws +// --------------------------------------------------------------------------- + +const logProviderError = vi.fn(async () => undefined) +const logProviderErrorForChannel = vi.fn(async () => undefined) +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: (...args: unknown[]) => logProviderError(...args), + logProviderErrorForChannel: (...args: unknown[]) => + logProviderErrorForChannel(...args), +})) + // --------------------------------------------------------------------------- // Redis distributedLock — execute fn immediately (no real lock) // --------------------------------------------------------------------------- @@ -162,27 +180,10 @@ vi.mock("@chatbotx.io/redis", () => ({ })) // --------------------------------------------------------------------------- -// Business buildContext +// Business buildContext (shared fake ctx, re-used across mock factories above) // --------------------------------------------------------------------------- const fakeCtx = { _brand: "ctx" } -const buildContext = vi.fn(async () => fakeCtx) -vi.mock("@chatbotx.io/business", () => ({ - buildContext, -})) - -// --------------------------------------------------------------------------- -// Utils createId — use importOriginal to preserve other named exports -// --------------------------------------------------------------------------- - -const createId = vi.fn(() => "generated-id") -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createId, - } -}) // --------------------------------------------------------------------------- // Logger — silence / spy @@ -202,7 +203,6 @@ vi.mock("../src/lib/logger", () => ({ const { handleSyncTag } = await import("../src/default/handlers/sync-tag") const { logger } = await import("../src/lib/logger") -const { db } = await import("@chatbotx.io/database/client") // --------------------------------------------------------------------------- // Helpers @@ -259,22 +259,20 @@ function makeContactInbox(overrides: Record = {}) { // --------------------------------------------------------------------------- beforeEach(() => { - queryResults.tagModelFindFirst = null - queryResults.integrationMessengerFindMany = [] - queryResults.integrationZaloFindMany = [] - queryResults.tagChannelFindFirst = null - queryResults.tagChannelFindMany = [] - queryResults.integrationMessengerFindFirst = null - queryResults.integrationZaloFindFirst = null - queryResults.contactInboxFindMany = [] - queryResults.contactToTagChannelFindMany = [] - queryResults.contactsToTagsFindMany = [] - queryResults.selectRows = [] - insertReturning.current = [] - updateReturning.current = [] - - // Reset all mock call counts — vi clears between tests via clearMocks:true - // in vitest config, but reset result holders manually above + queryResults.tag = null + queryResults.messengerIntegrations = [] + queryResults.zaloIntegrations = [] + queryResults.tagChannel = null + queryResults.tagChannelList = [] + queryResults.messengerIntegration = null + queryResults.zaloIntegration = null + queryResults.contactInboxes = [] + queryResults.contactTagChannelRows = [] + queryResults.contactInboxIdsForChannelPage = [] + queryResults.contactIdsByIds = [] + queryResults.taggedContactIdsPage = [] + + vi.clearAllMocks() }) // =========================================================================== @@ -304,7 +302,7 @@ describe("handleSyncTag — dispatch", () => { describe("syncTagCreate", () => { test("tag not found → early return, no SDK calls", async () => { - queryResults.tagModelFindFirst = null + queryResults.tag = null await handleSyncTag({ action: "create", @@ -314,15 +312,15 @@ describe("syncTagCreate", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() expect(zaloRunAction).not.toHaveBeenCalled() - expect(db.insert).not.toHaveBeenCalled() + expect(tagChannelInsertIfAbsent).not.toHaveBeenCalled() }) test("messenger integration with syncTagEnabledAt=null is skipped", async () => { - queryResults.tagModelFindFirst = { id: "tag-1", name: "VIP" } - queryResults.integrationMessengerFindMany = [ + queryResults.tag = { id: "tag-1", name: "VIP" } + queryResults.messengerIntegrations = [ makeMessengerIntegration({ syncTagEnabledAt: null }), ] - queryResults.integrationZaloFindMany = [] + queryResults.zaloIntegrations = [] await handleSyncTag({ action: "create", @@ -337,11 +335,10 @@ describe("syncTagCreate", () => { test("messenger sync-enabled → createLabel called under distributedLock with pageId and name", async () => { const tag = { id: "tag-1", name: "VIP" } const integration = makeMessengerIntegration() - queryResults.tagModelFindFirst = tag - queryResults.integrationMessengerFindMany = [integration] - queryResults.integrationZaloFindMany = [] - queryResults.tagChannelFindFirst = null // no existing tagChannel - insertReturning.current = [] + queryResults.tag = tag + queryResults.messengerIntegrations = [integration] + queryResults.zaloIntegrations = [] + queryResults.tagChannel = null // no existing tagChannel await handleSyncTag({ action: "create", @@ -373,10 +370,10 @@ describe("syncTagCreate", () => { const tag = { id: "tag-1", name: "VIP" } const integration = makeMessengerIntegration() const existing = makeTagChannel() - queryResults.tagModelFindFirst = tag - queryResults.integrationMessengerFindMany = [integration] - queryResults.integrationZaloFindMany = [] - queryResults.tagChannelFindFirst = existing + queryResults.tag = tag + queryResults.messengerIntegrations = [integration] + queryResults.zaloIntegrations = [] + queryResults.tagChannel = existing messengerRunChannelHandler.mockResolvedValueOnce({ id: "label-new-456" }) @@ -386,15 +383,18 @@ describe("syncTagCreate", () => { tagId: "tag-1", }) - expect(db.update).toHaveBeenCalled() - expect(db.insert).not.toHaveBeenCalled() + expect(tagChannelUpdateExternalLabelId).toHaveBeenCalledWith({ + id: existing.id, + externalLabelId: "label-new-456", + }) + expect(tagChannelInsertIfAbsent).not.toHaveBeenCalled() }) test("messenger createLabel failure is caught; warn logged; no throw", async () => { const tag = { id: "tag-1", name: "VIP" } - queryResults.tagModelFindFirst = tag - queryResults.integrationMessengerFindMany = [makeMessengerIntegration()] - queryResults.integrationZaloFindMany = [] + queryResults.tag = tag + queryResults.messengerIntegrations = [makeMessengerIntegration()] + queryResults.zaloIntegrations = [] // Make the lock fn throw (simulates createLabel failure propagating) runExclusive.mockRejectedValueOnce(new Error("FB 500")) @@ -403,14 +403,17 @@ describe("syncTagCreate", () => { ).resolves.toBeUndefined() expect(logger.warn).toHaveBeenCalled() + expect(logProviderError).toHaveBeenCalledWith( + expect.objectContaining({ provider: "messenger", workspaceId: "ws-1" }), + ) }) - test("zalo sync-enabled → insert tagChannel with onConflictDoNothing and correct externalLabelId=tag.name", async () => { + test("zalo sync-enabled → insertIfAbsent called with correct externalLabelId=tag.name", async () => { const tag = { id: "tag-1", name: "VIP" } const integration = makeZaloIntegration() - queryResults.tagModelFindFirst = tag - queryResults.integrationMessengerFindMany = [] - queryResults.integrationZaloFindMany = [integration] + queryResults.tag = tag + queryResults.messengerIntegrations = [] + queryResults.zaloIntegrations = [integration] await handleSyncTag({ action: "create", @@ -418,26 +421,21 @@ describe("syncTagCreate", () => { tagId: "tag-1", }) - expect(db.insert).toHaveBeenCalled() - const insertValuesCall = (insertChain.values as ReturnType) - .mock.calls[0]?.[0] - expect(insertValuesCall).toMatchObject({ + expect(tagChannelInsertIfAbsent).toHaveBeenCalledWith({ workspaceId: "ws-1", tagId: tag.id, channelType: "zalo", integrationId: integration.id, externalLabelId: tag.name, }) - // onConflictDoNothing must be called - expect(insertChain.onConflictDoNothing).toHaveBeenCalled() // No real Zalo API called (no create-empty-tag API) expect(zaloRunAction).not.toHaveBeenCalled() }) test("zalo sync disabled → skip insert", async () => { - queryResults.tagModelFindFirst = { id: "tag-1", name: "VIP" } - queryResults.integrationMessengerFindMany = [] - queryResults.integrationZaloFindMany = [ + queryResults.tag = { id: "tag-1", name: "VIP" } + queryResults.messengerIntegrations = [] + queryResults.zaloIntegrations = [ makeZaloIntegration({ syncTagEnabledAt: null }), ] @@ -447,7 +445,7 @@ describe("syncTagCreate", () => { tagId: "tag-1", }) - expect(db.insert).not.toHaveBeenCalled() + expect(tagChannelInsertIfAbsent).not.toHaveBeenCalled() }) }) @@ -457,7 +455,7 @@ describe("syncTagCreate", () => { describe("syncTagAttach", () => { test("tag not found → early return, no SDK calls", async () => { - queryResults.tagModelFindFirst = null + queryResults.tag = null await handleSyncTag({ action: "attach", @@ -478,11 +476,10 @@ describe("syncTagAttach", () => { externalLabelId: "label-ext-123", }) - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationMessengerFindFirst = integration - queryResults.tagChannelFindFirst = existingTagChannel - insertReturning.current = [] + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.messengerIntegration = integration + queryResults.tagChannel = existingTagChannel await handleSyncTag({ action: "attach", @@ -516,11 +513,11 @@ describe("syncTagAttach", () => { const integration = makeMessengerIntegration() const newTagChannel = makeTagChannel({ id: "tc-new" }) - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationMessengerFindFirst = integration - queryResults.tagChannelFindFirst = null - insertReturning.current = [newTagChannel] + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.messengerIntegration = integration + queryResults.tagChannel = null + tagChannelInsertOrFetch.mockResolvedValueOnce(newTagChannel) messengerRunChannelHandler.mockResolvedValueOnce({ id: "label-new-789" }) @@ -550,9 +547,9 @@ describe("syncTagAttach", () => { const tag = { id: "tag-1", name: "VIP", workspaceId: "ws-1" } const contactInbox = makeContactInbox() - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration({ + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.messengerIntegration = makeMessengerIntegration({ syncTagEnabledAt: null, }) @@ -580,10 +577,10 @@ describe("syncTagAttach", () => { externalLabelId: "VIP", }) - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationZaloFindFirst = integration - insertReturning.current = [newTagChannel] + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.zaloIntegration = integration + tagChannelUpsertByTagAndIntegration.mockResolvedValueOnce(newTagChannel) await handleSyncTag({ action: "attach", @@ -602,7 +599,7 @@ describe("syncTagAttach", () => { ) }) - test("zalo channel — onConflictDoUpdate upserts tagChannel", async () => { + test("zalo channel — upsertByTagAndIntegration called with correct externalLabelId (tag.name)", async () => { const tag = { id: "tag-1", name: "VIP", workspaceId: "ws-1" } const contactInbox = makeContactInbox({ channel: "zalo", @@ -616,10 +613,10 @@ describe("syncTagAttach", () => { externalLabelId: "VIP", }) - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationZaloFindFirst = integration - insertReturning.current = [tagChannel] + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.zaloIntegration = integration + tagChannelUpsertByTagAndIntegration.mockResolvedValueOnce(tagChannel) await handleSyncTag({ action: "attach", @@ -628,7 +625,13 @@ describe("syncTagAttach", () => { tagId: "tag-1", }) - expect(insertChain.onConflictDoUpdate).toHaveBeenCalled() + expect(tagChannelUpsertByTagAndIntegration).toHaveBeenCalledWith({ + workspaceId: "ws-1", + tagId: tag.id, + channelType: "zalo", + integrationId: integration.id, + externalLabelId: tag.name, + }) }) test("zalo integration with syncTagEnabledAt=null → skip tagFollower", async () => { @@ -639,9 +642,9 @@ describe("syncTagAttach", () => { sourceId: "zalo-user-999", }) - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [contactInbox] - queryResults.integrationZaloFindFirst = makeZaloIntegration({ + queryResults.tag = tag + queryResults.contactInboxes = [contactInbox] + queryResults.zaloIntegration = makeZaloIntegration({ syncTagEnabledAt: null, }) @@ -657,10 +660,8 @@ describe("syncTagAttach", () => { test("contactInbox not on messenger or zalo channel → no SDK calls", async () => { const tag = { id: "tag-1", name: "VIP", workspaceId: "ws-1" } - queryResults.tagModelFindFirst = tag - queryResults.contactInboxFindMany = [ - makeContactInbox({ channel: "webchat" }), - ] + queryResults.tag = tag + queryResults.contactInboxes = [makeContactInbox({ channel: "webchat" })] await handleSyncTag({ action: "attach", @@ -688,8 +689,8 @@ describe("syncTagDetach", () => { externalLabelId: "label-ext-123", sourceId: "psid-abc", } - queryResults.selectRows = [row] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() + queryResults.contactTagChannelRows = [row] + queryResults.messengerIntegration = makeMessengerIntegration() await handleSyncTag({ action: "detach", @@ -720,8 +721,8 @@ describe("syncTagDetach", () => { externalLabelId: "VIP", sourceId: "zalo-user-777", } - queryResults.selectRows = [row] - queryResults.integrationZaloFindFirst = makeZaloIntegration() + queryResults.contactTagChannelRows = [row] + queryResults.zaloIntegration = makeZaloIntegration() await handleSyncTag({ action: "detach", @@ -740,7 +741,7 @@ describe("syncTagDetach", () => { ) }) - test("local ContactToTagChannel row deleted even when API call throws", async () => { + test("local ContactToTagChannel row unlinked even when API call throws", async () => { const row = { tagChannelId: "tc-1", contactInboxId: "ci-1", @@ -749,8 +750,8 @@ describe("syncTagDetach", () => { externalLabelId: "label-ext-123", sourceId: "psid-abc", } - queryResults.selectRows = [row] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() + queryResults.contactTagChannelRows = [row] + queryResults.messengerIntegration = makeMessengerIntegration() // Make removeLabel throw messengerRunChannelHandler.mockRejectedValueOnce(new Error("FB offline")) @@ -764,9 +765,16 @@ describe("syncTagDetach", () => { }), ).resolves.toBeUndefined() - // Local delete still called - expect(db.delete).toHaveBeenCalled() + // Local unlink still called + expect(tagChannelUnlinkContactInbox).toHaveBeenCalledWith({ + tagChannelId: row.tagChannelId, + contactInboxId: row.contactInboxId, + }) expect(logger.warn).toHaveBeenCalled() + expect(logProviderErrorForChannel).toHaveBeenCalledWith( + row.channelType, + expect.objectContaining({ workspaceId: "ws-1", contactId: "contact-1" }), + ) }) test("error isolation — first row API failure does not abort second row", async () => { @@ -786,8 +794,8 @@ describe("syncTagDetach", () => { externalLabelId: "label-2", sourceId: "psid-2", } - queryResults.selectRows = [row1, row2] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() + queryResults.contactTagChannelRows = [row1, row2] + queryResults.messengerIntegration = makeMessengerIntegration() // First API call fails, second should succeed messengerRunChannelHandler @@ -803,13 +811,13 @@ describe("syncTagDetach", () => { }), ).resolves.toBeUndefined() - // Both rows should have had delete called (2 times) - expect(db.delete).toHaveBeenCalledTimes(2) + // Both rows should have had unlink called (2 times) + expect(tagChannelUnlinkContactInbox).toHaveBeenCalledTimes(2) // Second row's removeLabel was still attempted expect(messengerRunChannelHandler).toHaveBeenCalledTimes(2) }) - test("sync-disabled context (integration.syncTagEnabledAt=null) → no API call but local row still deleted", async () => { + test("sync-disabled context (integration.syncTagEnabledAt=null) → no API call but local row still unlinked", async () => { const row = { tagChannelId: "tc-1", contactInboxId: "ci-1", @@ -818,9 +826,9 @@ describe("syncTagDetach", () => { externalLabelId: "label-ext-123", sourceId: "psid-abc", } - queryResults.selectRows = [row] + queryResults.contactTagChannelRows = [row] // Sync disabled - queryResults.integrationMessengerFindFirst = makeMessengerIntegration({ + queryResults.messengerIntegration = makeMessengerIntegration({ syncTagEnabledAt: null, }) @@ -832,12 +840,12 @@ describe("syncTagDetach", () => { }) expect(messengerRunChannelHandler).not.toHaveBeenCalled() - // Local delete still runs - expect(db.delete).toHaveBeenCalled() + // Local unlink still runs + expect(tagChannelUnlinkContactInbox).toHaveBeenCalled() }) - test("empty rows → no delete, no API", async () => { - queryResults.selectRows = [] + test("empty rows → no unlink, no API", async () => { + queryResults.contactTagChannelRows = [] await handleSyncTag({ action: "detach", @@ -848,7 +856,7 @@ describe("syncTagDetach", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() expect(zaloRunAction).not.toHaveBeenCalled() - expect(db.delete).not.toHaveBeenCalled() + expect(tagChannelUnlinkContactInbox).not.toHaveBeenCalled() }) }) @@ -859,12 +867,13 @@ describe("syncTagDetach", () => { describe("syncTagDelete", () => { test("messenger channel → label API NOT called (temporarily disabled), tag deleted", async () => { const channel = { + id: "tc-1", channelType: "messenger", integrationId: "intg-msg-1", externalLabelId: "label-ext-123", } - queryResults.tagChannelFindMany = [channel] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() + queryResults.tagChannelList = [channel] + queryResults.messengerIntegration = makeMessengerIntegration() await handleSyncTag({ action: "delete", @@ -874,17 +883,21 @@ describe("syncTagDelete", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() // tag row deleted - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalledWith({ + workspaceId: "ws-1", + tagId: "tag-1", + }) }) test("zalo channel → label API NOT called (temporarily disabled), tag deleted", async () => { const channel = { + id: "tc-2", channelType: "zalo", integrationId: "intg-zalo-1", externalLabelId: "VIP", } - queryResults.tagChannelFindMany = [channel] - queryResults.integrationZaloFindFirst = makeZaloIntegration() + queryResults.tagChannelList = [channel] + queryResults.zaloIntegration = makeZaloIntegration() await handleSyncTag({ action: "delete", @@ -893,22 +906,24 @@ describe("syncTagDelete", () => { }) expect(zaloRunAction).not.toHaveBeenCalled() - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalled() }) test("processes every channel then deletes the tag row", async () => { const ch1 = { + id: "tc-1", channelType: "messenger", integrationId: "intg-msg-1", externalLabelId: "label-1", } const ch2 = { + id: "tc-2", channelType: "messenger", integrationId: "intg-msg-2", externalLabelId: "label-2", } - queryResults.tagChannelFindMany = [ch1, ch2] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() + queryResults.tagChannelList = [ch1, ch2] + queryResults.messengerIntegration = makeMessengerIntegration() await expect( handleSyncTag({ @@ -919,17 +934,19 @@ describe("syncTagDelete", () => { ).resolves.toBeUndefined() // Tag row delete still called - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalled() + expect(tagChannelDeleteById).toHaveBeenCalledTimes(2) }) test("sync-disabled context (syncTagEnabledAt=null) → skip API but still delete tag row", async () => { const channel = { + id: "tc-1", channelType: "messenger", integrationId: "intg-msg-1", externalLabelId: "label-ext-123", } - queryResults.tagChannelFindMany = [channel] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration({ + queryResults.tagChannelList = [channel] + queryResults.messengerIntegration = makeMessengerIntegration({ syncTagEnabledAt: null, }) @@ -941,11 +958,11 @@ describe("syncTagDelete", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() // Tag row still deleted - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalled() }) test("no channels → only tag row deleted", async () => { - queryResults.tagChannelFindMany = [] + queryResults.tagChannelList = [] await handleSyncTag({ action: "delete", @@ -955,23 +972,25 @@ describe("syncTagDelete", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() expect(zaloRunAction).not.toHaveBeenCalled() - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalled() }) test("multiple channels (messenger + zalo) → no API calls, tag deleted", async () => { const messengerChannel = { + id: "tc-1", channelType: "messenger", integrationId: "intg-msg-1", externalLabelId: "label-ext-123", } const zaloChannel = { + id: "tc-2", channelType: "zalo", integrationId: "intg-zalo-1", externalLabelId: "VIP", } - queryResults.tagChannelFindMany = [messengerChannel, zaloChannel] - queryResults.integrationMessengerFindFirst = makeMessengerIntegration() - queryResults.integrationZaloFindFirst = makeZaloIntegration() + queryResults.tagChannelList = [messengerChannel, zaloChannel] + queryResults.messengerIntegration = makeMessengerIntegration() + queryResults.zaloIntegration = makeZaloIntegration() await handleSyncTag({ action: "delete", @@ -981,15 +1000,15 @@ describe("syncTagDelete", () => { expect(messengerRunChannelHandler).not.toHaveBeenCalled() expect(zaloRunAction).not.toHaveBeenCalled() - expect(db.delete).toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).toHaveBeenCalled() }) // ── channel-scoped delete (inbound webhook) ────────────────────────────── test("channel-scoped → deletes only this channel's rows + contacts, keeps Tag, no channel API", async () => { - queryResults.tagChannelFindMany = [makeTagChannel()] // id tc-1, messenger - queryResults.contactToTagChannelFindMany = [{ contactInboxId: "ci-1" }] - queryResults.contactInboxFindMany = [{ contactId: "contact-1" }] + queryResults.tagChannelList = [makeTagChannel()] // id tc-1, messenger + queryResults.contactInboxIdsForChannelPage = [{ contactInboxId: "ci-1" }] + queryResults.contactIdsByIds = [{ contactId: "contact-1" }] await handleSyncTag({ action: "delete", @@ -1002,16 +1021,23 @@ describe("syncTagDelete", () => { // Inbound webhook: the channel already removed the label → no API call. expect(messengerRunChannelHandler).not.toHaveBeenCalled() // chunkById paged the channel's contact assignments - expect( - db.query.contactToTagChannelModel.findMany as ReturnType, - ).toHaveBeenCalled() - // exactly 3 deletes: contactToTagChannel + contactsToTags + tagChannel. - // The Tag row is NOT deleted (workspace delete = 4). - expect(db.delete).toHaveBeenCalledTimes(3) + expect(tagChannelListContactInboxIdsForChannelPage).toHaveBeenCalled() + // per-channel + contact cleanup happens; the Tag row is NOT deleted + // (workspace delete calls hardDeleteSoftDeleted; scoped delete does not). + expect(tagChannelDeleteLinksForChannel).toHaveBeenCalledWith({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1"], + }) + expect(tagChannelDeleteContactTagsForContacts).toHaveBeenCalledWith({ + tagId: "tag-1", + contactIds: ["contact-1"], + }) + expect(tagChannelDeleteById).toHaveBeenCalledWith({ id: "tc-1" }) + expect(tagServiceHardDeleteSoftDeleted).not.toHaveBeenCalled() }) test("channel-scoped → no-op when the tag is not mapped on that channel", async () => { - queryResults.tagChannelFindMany = [] + queryResults.tagChannelList = [] await handleSyncTag({ action: "delete", @@ -1022,6 +1048,7 @@ describe("syncTagDelete", () => { }) expect(messengerRunChannelHandler).not.toHaveBeenCalled() - expect(db.delete).not.toHaveBeenCalled() + expect(tagChannelDeleteById).not.toHaveBeenCalled() + expect(tagServiceHardDeleteSoftDeleted).not.toHaveBeenCalled() }) }) diff --git a/apps/worker/__tests__/sync-user-quota-reconcile.test.ts b/apps/worker/__tests__/sync-user-quota-reconcile.test.ts index c73b3dd512..34bc0cef4d 100644 --- a/apps/worker/__tests__/sync-user-quota-reconcile.test.ts +++ b/apps/worker/__tests__/sync-user-quota-reconcile.test.ts @@ -1,9 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { mockCountDistinct } = vi.hoisted(() => ({ - mockCountDistinct: vi.fn((column: unknown) => ({ countDistinct: column })), -})) - // --------------------------------------------------------------------------- // Regression: the Redis→DB reconcile must write the *current* authoritative // COUNT(*) for contacts/workspaces/channels and COUNT(DISTINCT userId) for @@ -15,11 +11,14 @@ const { mockCountDistinct } = vi.hoisted(() => ({ // --------------------------------------------------------------------------- const state = { - // Dequeued by each terminal `.where()` — order: - // [contactsCount, teamMembersCount, workspacesCount, channelsCount]. - countResults: [] as number[], + // Markers reconcileUserSelfUsage reports back to the handler. The counts and + // the direct-assignment upsert itself now live inside the service — see + // packages/business/__tests__/user-quota-reconcile-self.test.ts. stored: null as Record | null, - capturedSets: [] as Record[], + // Every macUsed value the handler persisted, in order. + persistedMac: [] as number[], + // Truthy once reconcileUserSelfUsage ran for a user (the self-count path). + selfReconciledUsers: [] as string[], hsetCalls: [] as unknown[][], hmgetResult: [null, null] as (string | null)[], // Owner MAC count returned by the (mocked) ContactActiveMonthly ledger. @@ -27,57 +26,19 @@ const state = { // Existence filter: `null` means every id in the batch exists; a Set restricts // which ids the User table "contains" (the rest are treated as deleted ghosts). existingUserIds: null as Set | null, - // When set, the UserQuota upsert rejects with this error, to exercise both the - // FK race (user deleted between filter and upsert) and unrelated failures. + // When set, reconcileUserSelfUsage/reconcileOwnerPoolUsage reject with this + // error, to exercise both the FK race (user deleted mid-run) and unrelated + // failures. insertRejectError: null as Error | null, } -function makeSelectChain() { - const chain: Record = {} - chain.from = vi.fn(() => chain) - chain.innerJoin = vi.fn(() => chain) - // Every remaining `db.select` in the handler is a scalar COUNT; the existence - // filter moved to `userService.listExistingIds`. - chain.where = vi.fn(() => - Promise.resolve([{ count: state.countResults.shift() ?? 0 }]), - ) - return chain -} - -function makeInsertChain() { - const chain: Record = {} - chain.values = vi.fn(() => chain) - chain.onConflictDoUpdate = vi.fn((arg: { set: Record }) => { - if (state.insertRejectError) { - return Promise.reject(state.insertRejectError) - } - state.capturedSets.push(arg.set) - return Promise.resolve() - }) - return chain -} - +// The handler still consults `isForeignKeyViolationError` directly to decide +// whether a reconcile failure is a benign ghost-user race. vi.mock("@chatbotx.io/database/client", () => ({ - db: { - select: vi.fn(() => makeSelectChain()), - insert: vi.fn(() => makeInsertChain()), - query: { - userQuotaModel: { findFirst: vi.fn(async () => state.stored) }, - }, - }, - and: vi.fn((...a: unknown[]) => ({ and: a })), - count: vi.fn(() => ({ count: true })), - countDistinct: mockCountDistinct, - eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), isForeignKeyViolationError: vi.fn( - (error: unknown) => - error instanceof Error && error.message.includes("FK violation"), + (error: unknown, constraint: string) => + error instanceof Error && error.message === constraint, ), - ne: vi.fn((a: unknown, b: unknown) => ({ ne: [a, b] })), - sql: (strings: TemplateStringsArray, ...vals: unknown[]) => ({ - __sql: strings.join("?"), - vals, - }), })) // The handler imports a few lightweight helpers from `@chatbotx.io/business`. @@ -94,15 +55,41 @@ vi.mock("@chatbotx.io/business", () => ({ }, userQuotaService: { invalidate: vi.fn(async () => undefined), - reconcileOwnerPoolUsage: vi.fn(async () => undefined), - countDistinctTeamMembersForOwner: vi.fn( - async () => state.countResults.shift() ?? 0, - ), + reconcileOwnerPoolUsage: vi.fn((_userId: string, _tenantId: string) => { + if (state.insertRejectError) { + return Promise.reject(state.insertRejectError) + } + return Promise.resolve(undefined) + }), clearLiveCounters: vi.fn(async () => undefined), + // The authoritative self-count + direct-assignment upsert moved here from + // the handler; the handler now only consumes the four billing markers. + reconcileUserSelfUsage: vi.fn((userId: string) => { + if (state.insertRejectError) { + return Promise.reject(state.insertRejectError) + } + state.selfReconciledUsers.push(userId) + return Promise.resolve({ + macUsed: (state.stored?.macUsed as number | undefined) ?? 0, + periodStart: + (state.stored?.periodStart as Date | null | undefined) ?? null, + periodEnd: (state.stored?.periodEnd as Date | null | undefined) ?? null, + monthlyBotMessagesPeriodStart: + (state.stored?.monthlyBotMessagesPeriodStart as + | Date + | null + | undefined) ?? null, + }) + }), + persistMacUsed: vi.fn((_userId: string, value: number) => { + state.persistedMac.push(value) + return Promise.resolve() + }), + applyMonthlyBotMessagesReset: vi.fn(async () => undefined), }, - // The ghost-id existence filter now lives on the service, not a raw - // `db.select` in the handler. `existingUserIds === null` means every id in the - // batch still has a User row. + // The ghost-id existence filter lives on the service, not a raw `db.select` + // in the handler. `existingUserIds === null` means every id in the batch + // still has a User row. userService: { listExistingIds: vi.fn(async ({ ids }: { ids: string[] }) => ids.filter( @@ -125,25 +112,6 @@ vi.mock("@chatbotx.io/utils", () => ({ liveKeyFor: (label: string, id: string) => `${label}-live:${id}`, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - contactModel: { workspaceId: "contact.workspaceId" }, - inboxModel: { workspaceId: "inbox.workspaceId" }, - userQuotaModel: { - userId: "userQuota.userId", - contactsUsed: "userQuota.contactsUsed", - teamMembersUsed: "userQuota.teamMembersUsed", - workspacesUsed: "userQuota.workspacesUsed", - channelsUsed: "userQuota.channelsUsed", - macUsed: "userQuota.macUsed", - }, - workspaceMemberModel: { - workspaceId: "wm.workspaceId", - userId: "wm.userId", - role: "wm.role", - }, - workspaceModel: { id: "ws.id", ownerId: "ws.ownerId" }, -})) - const redisClient = { hset: vi.fn((...args: unknown[]) => { state.hsetCalls.push(args) @@ -182,7 +150,8 @@ const { tenantService, userQuotaService } = (await import( } userQuotaService: { reconcileOwnerPoolUsage: ReturnType - countDistinctTeamMembersForOwner: ReturnType + reconcileUserSelfUsage: ReturnType + persistMacUsed: ReturnType clearLiveCounters: ReturnType } } @@ -191,11 +160,11 @@ const { logger } = (await import("../src/lib/logger")) as unknown as { logger: { info: ReturnType; error: ReturnType } } -describe("reconcileUser — contacts/teamMembers reflect the current count", () => { +describe("reconcileUser — the non-reseller path delegates the self-count", () => { beforeEach(() => { - state.countResults = [] state.stored = null - state.capturedSets = [] + state.persistedMac = [] + state.selfReconciledUsers = [] state.hsetCalls = [] state.hmgetResult = [null, null] state.ledgerMac = 0 @@ -203,74 +172,26 @@ describe("reconcileUser — contacts/teamMembers reflect the current count", () countActiveContactsForOwner.mockClear() }) - test("writes the recomputed count even when LOWER than the stored value (deletions free slots)", async () => { - // Source-of-truth counts after deletions. Team members are distinct humans: - // [contacts, teamMembers, workspaces, channels]. - state.countResults = [3, 1, 2, 4] - // DB previously stored a higher (high-water) value. + // The authoritative COUNT(*) reads, the direct-assignment (never GREATEST) + // upsert, and the live-counter mirror all moved into + // userQuotaService.reconcileUserSelfUsage — pinned in + // packages/business/__tests__/user-quota-reconcile-self.test.ts. What the + // handler still owns is *calling* it for the right user, exactly once. + test("reconciles the user's own usage exactly once for a non-reseller", async () => { state.stored = { - contactsUsed: 10, - teamMembersUsed: 5, - workspacesUsed: 8, - channelsUsed: 9, macUsed: 0, periodStart: null, + periodEnd: null, + monthlyBotMessagesPeriodStart: null, } await reconcileUser("user-1") - // The reconcile upsert must persist the exact current count, not GREATEST. - const set = state.capturedSets[0] - expect(set.contactsUsed).toBe(3) - expect(set.teamMembersUsed).toBe(1) - expect(set.workspacesUsed).toBe(2) - expect(set.channelsUsed).toBe(4) - - // The live Redis counter must mirror the current count, not the stale values. - expect(state.hsetCalls[0]).toEqual([ - "user-quota-live:user-1", - "contacts", - "3", - "teamMembers", - "1", - "workspaces", - "2", - "channels", - "4", - ]) - }) - - test("counts a human shared across workspaces once", async () => { - // Two workspaces with the owner and one shared teammate produce four - // membership rows but only two distinct people. - state.countResults = [0, 2, 2, 0] - - await reconcileUser("user-1") - - expect( - userQuotaService.countDistinctTeamMembersForOwner, - ).toHaveBeenCalledWith("user-1") - expect(state.capturedSets[0].teamMembersUsed).toBe(2) - }) - - test("writes increases too (count grew since last sync)", async () => { - state.countResults = [42, 7, 3, 5] - state.stored = { - contactsUsed: 40, - teamMembersUsed: 6, - workspacesUsed: 2, - channelsUsed: 4, - macUsed: 0, - periodStart: null, - } - - await reconcileUser("user-2") - - const set = state.capturedSets[0] - expect(set.contactsUsed).toBe(42) - expect(set.teamMembersUsed).toBe(7) - expect(set.workspacesUsed).toBe(3) - expect(set.channelsUsed).toBe(5) + expect(userQuotaService.reconcileUserSelfUsage).toHaveBeenCalledTimes(1) + expect(userQuotaService.reconcileUserSelfUsage).toHaveBeenCalledWith( + "user-1", + ) + expect(state.selfReconciledUsers).toEqual(["user-1"]) }) }) @@ -278,8 +199,8 @@ describe("reconcileUser — macUsed is derived from the ContactActiveMonthly led const PERIOD = "2026-06-01T00:00:00.000Z" beforeEach(() => { - state.countResults = [0, 0, 0, 0] - state.capturedSets = [] + state.persistedMac = [] + state.selfReconciledUsers = [] state.hsetCalls = [] redisClient.hset.mockClear() countActiveContactsForOwner.mockClear() @@ -291,13 +212,10 @@ describe("reconcileUser — macUsed is derived from the ContactActiveMonthly led state.hmgetResult = ["3", PERIOD] state.ledgerMac = 7 state.stored = { - contactsUsed: 0, - teamMembersUsed: 0, - workspacesUsed: 0, - channelsUsed: 0, macUsed: 5, periodStart: new Date(PERIOD), periodEnd: new Date("2026-07-01T00:00:00.000Z"), + monthlyBotMessagesPeriodStart: null, } await reconcileUser("user-1") @@ -314,34 +232,31 @@ describe("reconcileUser — macUsed is derived from the ContactActiveMonthly led PERIOD, ]) // macUsed is persisted to the ledger count (self-heals the drift). - expect(state.capturedSets.some((set) => set.macUsed === 7)).toBe(true) + expect(state.persistedMac).toContain(7) }) test("lifetime plan (no periodEnd) keeps the accumulate path, not the ledger", async () => { state.hmgetResult = ["10", PERIOD] state.ledgerMac = 4 state.stored = { - contactsUsed: 0, - teamMembersUsed: 0, - workspacesUsed: 0, - channelsUsed: 0, macUsed: 10, periodStart: new Date(PERIOD), periodEnd: null, + monthlyBotMessagesPeriodStart: null, } await reconcileUser("user-1") expect(countActiveContactsForOwner).not.toHaveBeenCalled() // No mac drift to persist (live === DB within the stable lifetime period). - expect(state.capturedSets.some((set) => "macUsed" in set)).toBe(false) + expect(state.persistedMac).toHaveLength(0) }) }) describe("reconcileUser — reseller owner reconciles the tenant pool", () => { beforeEach(() => { - state.countResults = [] - state.capturedSets = [] + state.persistedMac = [] + state.selfReconciledUsers = [] state.hsetCalls = [] tenantService.findByOwner.mockReset() tenantService.findByOwner.mockResolvedValue(undefined) @@ -362,8 +277,9 @@ describe("reconcileUser — reseller owner reconciles the tenant pool", () => { "owner-1", "tenant-1", ) - // ...so the per-user self-count upsert never runs for the owner. - expect(state.capturedSets).toHaveLength(0) + // ...so the per-user self-count never runs for the owner. + expect(userQuotaService.reconcileUserSelfUsage).not.toHaveBeenCalled() + expect(state.selfReconciledUsers).toHaveLength(0) }) test("a suspended tenant falls through to the per-user self-count", async () => { @@ -372,27 +288,24 @@ describe("reconcileUser — reseller owner reconciles the tenant pool", () => { ownerId: "owner-1", status: "suspended", }) - state.countResults = [1, 2, 3, 4] state.stored = { - contactsUsed: 0, - teamMembersUsed: 0, - workspacesUsed: 0, - channelsUsed: 0, macUsed: 0, periodStart: null, + periodEnd: null, + monthlyBotMessagesPeriodStart: null, } await reconcileUser("owner-1") expect(userQuotaService.reconcileOwnerPoolUsage).not.toHaveBeenCalled() - expect(state.capturedSets.length).toBeGreaterThan(0) + expect(state.selfReconciledUsers).toEqual(["owner-1"]) }) }) describe("syncUserQuota — cold reseller owners are included via DB fallback", () => { beforeEach(() => { - state.countResults = [] - state.capturedSets = [] + state.persistedMac = [] + state.selfReconciledUsers = [] state.hsetCalls = [] redisClient.scan.mockReset() // Simulate empty Redis: no live keys for any user @@ -447,7 +360,7 @@ describe("syncUserQuota — cold reseller owners are included via DB fallback", await syncUserQuota() expect(userQuotaService.reconcileOwnerPoolUsage).not.toHaveBeenCalled() - expect(state.capturedSets).toHaveLength(0) + expect(state.selfReconciledUsers).toHaveLength(0) }) }) @@ -459,18 +372,17 @@ describe("syncUserQuota — cold reseller owners are included via DB fallback", // --------------------------------------------------------------------------- describe("syncUserQuota — skips and cleans up deleted (ghost) users", () => { beforeEach(() => { - state.countResults = [0, 0, 0, 0] - state.capturedSets = [] - state.hsetCalls = [] state.stored = { - contactsUsed: 0, - teamMembersUsed: 0, - workspacesUsed: 0, - channelsUsed: 0, macUsed: 0, periodStart: null, + periodEnd: null, + monthlyBotMessagesPeriodStart: null, } + state.persistedMac = [] + state.selfReconciledUsers = [] + state.hsetCalls = [] state.existingUserIds = null + state.insertRejectError = null redisClient.scan.mockReset() redisClient.scan.mockResolvedValue(["0", []]) tenantService.findByOwner.mockReset() @@ -495,30 +407,29 @@ describe("syncUserQuota — skips and cleans up deleted (ghost) users", () => { expect(userQuotaService.clearLiveCounters).not.toHaveBeenCalledWith( "real-1", ) - // The surviving user is still reconciled (its upsert ran once). - expect(state.capturedSets).toHaveLength(1) + // The surviving user is still reconciled (its self-count ran once). + expect(state.selfReconciledUsers).toEqual(["real-1"]) }) }) describe("reconcileUser — a user deleted mid-run is skipped, not error-logged", () => { beforeEach(() => { - state.countResults = [0, 0, 0, 0] - state.capturedSets = [] - state.hsetCalls = [] state.stored = null + state.persistedMac = [] + state.selfReconciledUsers = [] + state.hsetCalls = [] state.insertRejectError = null tenantService.findByOwner.mockReset() tenantService.findByOwner.mockResolvedValue(undefined) userQuotaService.clearLiveCounters.mockClear() userQuotaService.reconcileOwnerPoolUsage.mockClear() - userQuotaService.reconcileOwnerPoolUsage.mockResolvedValue(undefined) logger.info.mockClear() logger.error.mockClear() }) test("a foreign-key violation on the per-user upsert clears the stale key without throwing", async () => { // The user vanished before the upsert committed. - state.insertRejectError = new Error("FK violation (test)") + state.insertRejectError = new Error("UserQuota_userId_User_id_fkey") await expect(reconcileUser("ghost-2")).resolves.toBeUndefined() @@ -538,9 +449,7 @@ describe("reconcileUser — a user deleted mid-run is skipped, not error-logged" ownerId: "owner-ghost", status: "active", }) - userQuotaService.reconcileOwnerPoolUsage.mockRejectedValueOnce( - new Error("FK violation (test)"), - ) + state.insertRejectError = new Error("UserQuota_userId_User_id_fkey") await expect(reconcileUser("owner-ghost")).resolves.toBeUndefined() diff --git a/apps/worker/__tests__/trigger-action-executor-add-tag.test.ts b/apps/worker/__tests__/trigger-action-executor-add-tag.test.ts index 9e89411952..cd03eba8cc 100644 --- a/apps/worker/__tests__/trigger-action-executor-add-tag.test.ts +++ b/apps/worker/__tests__/trigger-action-executor-add-tag.test.ts @@ -1,46 +1,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ - conversationFindFirst: vi.fn(), + findLatestCreatedByContact: vi.fn(), findByIdForContact: vi.fn(), findMostRecentByContact: vi.fn(), - tagFindMany: vi.fn(), - insertReturning: vi.fn(), + attachExistingToContactForTrigger: vi.fn(), enqueueAttach: vi.fn(), enqueueTagAppliedEvaluations: vi.fn(), enqueueEvent: vi.fn(), buildSourceKey: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - conversationModel: { - findFirst: (...args: unknown[]) => mocks.conversationFindFirst(...args), - }, - tagModel: { - findMany: (...args: unknown[]) => mocks.tagFindMany(...args), - }, - }, - insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: (...args: unknown[]) => mocks.insertReturning(...args), - }), - }), - }), - delete: () => ({ where: vi.fn() }), - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (col: unknown, val: unknown) => ({ eq: [col, val] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ - contactsToTagsModel: { - contactId: "contactsToTagsModel.contactId", - tagId: "contactsToTagsModel.tagId", - }, metaCapiEventChannelSchema: { safeParse: (value: unknown) => value === "messenger" || value === "instagram" || value === "whatsapp" @@ -60,7 +31,18 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ vi.mock("@chatbotx.io/business", () => ({ contactCustomFieldService: {}, - conversationService: {}, + conversationService: { + findLatestCreatedByContact: (...args: unknown[]) => + mocks.findLatestCreatedByContact(...args), + }, + tagService: { + attachExistingToContactForTrigger: (...args: unknown[]) => + mocks.attachExistingToContactForTrigger(...args), + detachFromContactForTrigger: vi.fn(), + }, + flowService: {}, + workspaceMemberService: {}, + inboxService: {}, tagSyncService: { enqueueAttach: (...args: unknown[]) => mocks.enqueueAttach(...args), }, @@ -78,6 +60,16 @@ vi.mock("@chatbotx.io/events/context", () => ({ webhookChannelOrigin: vi.fn(() => "webhook"), })) +// `capi-input-error.ts` (imported transitively for the sendMetaCapiEvent +// branch) pulls `logProviderError` from this separate package subpath, which +// is not covered by the `@chatbotx.io/business` mock above (subpath exports +// are independent module specifiers). Left unmocked, it loads the real +// `@chatbotx.io/database/client` and relations graph against the partial +// schema mock below and crashes at import time. +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), +})) + vi.mock("@chatbotx.io/logger", () => ({ default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, getChildLogger: () => ({ @@ -120,7 +112,7 @@ const { ActionExecutor } = await import( describe("ActionExecutor addTag", () => { beforeEach(() => { vi.clearAllMocks() - mocks.conversationFindFirst.mockResolvedValue({ + mocks.findLatestCreatedByContact.mockResolvedValue({ id: "conv-1", contactId: "contact-1", workspaceId: "ws-1", @@ -134,8 +126,9 @@ describe("ActionExecutor addTag", () => { }) test("enqueues tag sync and ads conversion tagApplied evaluation for newly-linked tags", async () => { - mocks.tagFindMany.mockResolvedValue([{ id: "tag-1" }, { id: "tag-2" }]) - mocks.insertReturning.mockResolvedValue([{ tagId: "tag-1" }]) + mocks.attachExistingToContactForTrigger.mockResolvedValue([ + { tagId: "tag-1" }, + ]) const executor = new ActionExecutor() await executor.execute({ @@ -159,8 +152,7 @@ describe("ActionExecutor addTag", () => { }) test("does not enqueue when no tags were newly linked", async () => { - mocks.tagFindMany.mockResolvedValue([{ id: "tag-1" }]) - mocks.insertReturning.mockResolvedValue([]) + mocks.attachExistingToContactForTrigger.mockResolvedValue([]) const executor = new ActionExecutor() await executor.execute({ @@ -174,7 +166,7 @@ describe("ActionExecutor addTag", () => { }) test("skips entirely when no conversation is found for the contact", async () => { - mocks.conversationFindFirst.mockResolvedValue(null) + mocks.findLatestCreatedByContact.mockResolvedValue(null) const executor = new ActionExecutor() await executor.execute({ @@ -183,7 +175,7 @@ describe("ActionExecutor addTag", () => { workspaceId: "ws-1", }) - expect(mocks.tagFindMany).not.toHaveBeenCalled() + expect(mocks.attachExistingToContactForTrigger).not.toHaveBeenCalled() expect(mocks.enqueueTagAppliedEvaluations).not.toHaveBeenCalled() }) diff --git a/apps/worker/__tests__/trigger-action-executor-bot-field.test.ts b/apps/worker/__tests__/trigger-action-executor-bot-field.test.ts index faa2cc1666..844ff1cc90 100644 --- a/apps/worker/__tests__/trigger-action-executor-bot-field.test.ts +++ b/apps/worker/__tests__/trigger-action-executor-bot-field.test.ts @@ -11,37 +11,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest" // --------------------------------------------------------------------------- const mocks = vi.hoisted(() => ({ - conversationFindFirst: vi.fn(), + findLatestCreatedByContact: vi.fn(), setValues: vi.fn(), deleteByCustomFieldId: vi.fn(), applyValueOperation: vi.fn(), clearValueByKey: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - conversationModel: { - findFirst: (...args: unknown[]) => mocks.conversationFindFirst(...args), - }, - }, - insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ returning: vi.fn() }), - }), - }), - delete: () => ({ where: vi.fn() }), - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (col: unknown, val: unknown) => ({ eq: [col, val] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ - contactsToTagsModel: { - contactId: "contactsToTagsModel.contactId", - tagId: "contactsToTagsModel.tagId", - }, metaCapiEventChannelSchema: { safeParse: () => ({ success: false }) }, })) @@ -66,7 +43,14 @@ vi.mock("@chatbotx.io/business", () => ({ deleteByCustomFieldId: (...args: unknown[]) => mocks.deleteByCustomFieldId(...args), }, - conversationService: {}, + conversationService: { + findLatestCreatedByContact: (...args: unknown[]) => + mocks.findLatestCreatedByContact(...args), + }, + tagService: {}, + flowService: {}, + workspaceMemberService: {}, + inboxService: {}, metaConversionsService: { enqueueEvent: vi.fn(), buildSourceKey: vi.fn(), @@ -78,6 +62,16 @@ vi.mock("@chatbotx.io/events/context", () => ({ webhookChannelOrigin: vi.fn(() => "webhook"), })) +// `capi-input-error.ts` (imported transitively for the sendMetaCapiEvent +// branch) pulls `logProviderError` from this separate package subpath, which +// is not covered by the `@chatbotx.io/business` mock above (subpath exports +// are independent module specifiers). Left unmocked, it loads the real +// `@chatbotx.io/database/client` and relations graph against the partial +// schema mock below and crashes at import time. +vi.mock("@chatbotx.io/business/error-log", () => ({ + logProviderError: vi.fn(), +})) + vi.mock("@chatbotx.io/logger", () => ({ default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, getChildLogger: () => ({ @@ -120,7 +114,7 @@ const { ActionExecutor } = await import( describe("ActionExecutor setCustomField / clearCustomField — field-reference dispatch", () => { beforeEach(() => { vi.clearAllMocks() - mocks.conversationFindFirst.mockResolvedValue({ + mocks.findLatestCreatedByContact.mockResolvedValue({ id: "conv-1", contactId: "contact-1", workspaceId: "ws-1", diff --git a/apps/worker/__tests__/trigger-action-executor-send-meta-capi-event.test.ts b/apps/worker/__tests__/trigger-action-executor-send-meta-capi-event.test.ts index 7c0f18ec7f..6968ef2815 100644 --- a/apps/worker/__tests__/trigger-action-executor-send-meta-capi-event.test.ts +++ b/apps/worker/__tests__/trigger-action-executor-send-meta-capi-event.test.ts @@ -27,7 +27,7 @@ import { z } from "zod" const plainNumberPattern = /^\d+(\.\d+)?$/ const mocks = vi.hoisted(() => ({ - conversationFindFirst: vi.fn(), + findLatestCreatedByContact: vi.fn(), findByIdForContact: vi.fn(), findMostRecentByContact: vi.fn(), enqueueEvent: vi.fn(), @@ -38,30 +38,7 @@ const mocks = vi.hoisted(() => ({ ), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - conversationModel: { - findFirst: (...args: unknown[]) => mocks.conversationFindFirst(...args), - }, - }, - insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ returning: vi.fn() }), - }), - }), - delete: () => ({ where: vi.fn() }), - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (col: unknown, val: unknown) => ({ eq: [col, val] }), - inArray: (col: unknown, vals: unknown) => ({ inArray: [col, vals] }), -})) - vi.mock("@chatbotx.io/database/schema", () => ({ - contactsToTagsModel: { - contactId: "contactsToTagsModel.contactId", - tagId: "contactsToTagsModel.tagId", - }, metaCapiEventChannelSchema: { safeParse: (value: unknown) => value === "messenger" || value === "instagram" || value === "whatsapp" @@ -81,9 +58,16 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ vi.mock("@chatbotx.io/business", () => ({ contactCustomFieldService: {}, - conversationService: {}, + conversationService: { + findLatestCreatedByContact: (...args: unknown[]) => + mocks.findLatestCreatedByContact(...args), + }, + tagService: {}, tagSyncService: {}, adsConversionService: {}, + flowService: {}, + workspaceMemberService: {}, + inboxService: {}, metaConversionsService: { enqueueEvent: (...args: unknown[]) => mocks.enqueueEvent(...args), buildSourceKey: (...args: unknown[]) => mocks.buildSourceKey(...args), @@ -137,7 +121,7 @@ describe("ActionExecutor sendMetaCapiEvent", () => { mocks.resolveContactVariablesDeep.mockImplementation( async (_contactId: string, value: unknown) => value, ) - mocks.conversationFindFirst.mockResolvedValue({ + mocks.findLatestCreatedByContact.mockResolvedValue({ id: "conv-1", contactId: "contact-1", workspaceId: "ws-1", diff --git a/apps/worker/__tests__/webhook-executor-payloads.test.ts b/apps/worker/__tests__/webhook-executor-payloads.test.ts index 511aa47931..8a0beefad4 100644 --- a/apps/worker/__tests__/webhook-executor-payloads.test.ts +++ b/apps/worker/__tests__/webhook-executor-payloads.test.ts @@ -3,28 +3,23 @@ import type { MatchableEventType } from "@chatbotx.io/events" import { beforeEach, describe, expect, test, vi } from "vitest" import type { WebhookWithConditions } from "../src/webhook/types" -const { assertPublicUrl, contactFindById, listCustomFields, tagFindFirst } = - vi.hoisted(() => ({ - assertPublicUrl: vi.fn().mockResolvedValue(undefined), - contactFindById: vi.fn(), - listCustomFields: vi.fn(), - tagFindFirst: vi.fn(), - })) +const { + assertPublicUrl, + contactFindById, + listCustomFields, + findNameByIdForWorkspace, +} = vi.hoisted(() => ({ + assertPublicUrl: vi.fn().mockResolvedValue(undefined), + contactFindById: vi.fn(), + listCustomFields: vi.fn(), + findNameByIdForWorkspace: vi.fn(), +})) vi.mock("@chatbotx.io/business", () => ({ assertPublicUrl, contactCustomFieldService: { listWithDefinitions: listCustomFields }, contactService: { findById: contactFindById }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - tagModel: { - findFirst: tagFindFirst, - }, - }, - }, + tagService: { findNameByIdForWorkspace }, })) vi.mock("../src/lib/logger", () => ({ @@ -47,11 +42,10 @@ const webhook = { url: "https://example.com/webhook", } as WebhookWithConditions -type TagQuery = { where: { id: string; workspaceId?: string } } - // Tag ids are globally unique, so an id-only lookup resolves a row from any -// workspace. These rows let the mock mimic SQL semantics — a `where` without -// `workspaceId` matches across workspaces — instead of hiding the difference. +// workspace. This mock mimics the real repository's SQL semantics — a lookup +// scoped to a `workspaceId` that doesn't own the row resolves to nothing — +// instead of hiding the difference. const tagRows = [ { id: "tag-1", workspaceId: "workspace-1", name: "VIP" }, { @@ -237,7 +231,7 @@ describe("WebhookExecutor payloads", () => { beforeEach(() => { vi.clearAllMocks() fetchMock.mockResolvedValue(new Response(null, { status: 200 })) - tagFindFirst.mockResolvedValue({ name: "VIP" }) + findNameByIdForWorkspace.mockResolvedValue("VIP") contactFindById.mockResolvedValue({ id: "contact-1", fullName: "Ada Lovelace", @@ -274,15 +268,15 @@ describe("WebhookExecutor payloads", () => { // to the workspace the webhook is registered under. An id-only lookup would // put another tenant's tag name in this workspace's outbound payload. test("does not leak a tag that belongs to another workspace", async () => { - tagFindFirst.mockImplementation((query: TagQuery) => - Promise.resolve( - tagRows.find( - (row) => - row.id === query.where.id && - (query.where.workspaceId === undefined || - row.workspaceId === query.where.workspaceId), - ), - ), + findNameByIdForWorkspace.mockImplementation( + (query: { workspaceId: string; id: string }) => { + const row = tagRows.find( + (candidate) => + candidate.id === query.id && + candidate.workspaceId === query.workspaceId, + ) + return Promise.resolve(row?.name ?? null) + }, ) const payload = await buildWebhookPayload({ diff --git a/apps/worker/__tests__/webhook-matcher-datetime.test.ts b/apps/worker/__tests__/webhook-matcher-datetime.test.ts index 1adf118ce2..f7c1f0ba54 100644 --- a/apps/worker/__tests__/webhook-matcher-datetime.test.ts +++ b/apps/worker/__tests__/webhook-matcher-datetime.test.ts @@ -3,37 +3,35 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { buildPayload, - contactCustomFieldFindFirst, + contactCustomFieldFindValue, + customFieldFindBy, executeWebhook, loggerError, - webhookFindMany, + listActiveWithConditions, workspaceFind, } = vi.hoisted(() => ({ buildPayload: vi.fn(), - contactCustomFieldFindFirst: vi.fn(), + contactCustomFieldFindValue: vi.fn(), + customFieldFindBy: vi.fn(), executeWebhook: vi.fn(), loggerError: vi.fn(), - webhookFindMany: vi.fn(), + listActiveWithConditions: vi.fn(), workspaceFind: vi.fn(), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - contactCustomFieldModel: { - findFirst: contactCustomFieldFindFirst, - }, - webhookModel: { - findMany: webhookFindMany, - }, - }, - }, -})) - vi.mock("@chatbotx.io/business", () => ({ workspaceService: { find: workspaceFind, }, + webhookService: { + listActiveWithConditions, + }, + contactCustomFieldService: { + findValue: contactCustomFieldFindValue, + }, + customFieldService: { + findBy: customFieldFindBy, + }, })) vi.mock("../src/webhook/services/webhook-executor.service", () => ({ @@ -111,12 +109,12 @@ const datetimePayload = { event: "datetime_based_trigger", sourceId: "cf-1" } describe("WebhookMatcherService datetime events", () => { beforeEach(() => { vi.clearAllMocks() - contactCustomFieldFindFirst.mockRejectedValue( + contactCustomFieldFindValue.mockRejectedValue( new Error("datetime should not be re-evaluated by webhook matcher"), ) buildPayload.mockResolvedValue(datetimePayload) executeWebhook.mockResolvedValue(undefined) - webhookFindMany.mockResolvedValue([webhook]) + listActiveWithConditions.mockResolvedValue([webhook]) workspaceFind.mockResolvedValue({ id: "workspace-1", timezone: "UTC" }) }) @@ -125,7 +123,7 @@ describe("WebhookMatcherService datetime events", () => { await matcher.findAndExecuteWebhooks(datetimeEvent) - expect(contactCustomFieldFindFirst).not.toHaveBeenCalled() + expect(contactCustomFieldFindValue).not.toHaveBeenCalled() expect(buildPayload).toHaveBeenCalledWith( expect.objectContaining({ eventType: triggerEventTypes.enum.dateTimeBasedTrigger, @@ -146,7 +144,7 @@ describe("WebhookMatcherService datetime events", () => { eventData: { sourceId: "cf-other" }, }) - expect(contactCustomFieldFindFirst).not.toHaveBeenCalled() + expect(contactCustomFieldFindValue).not.toHaveBeenCalled() expect(buildPayload).not.toHaveBeenCalled() expect(executeWebhook).not.toHaveBeenCalled() }) @@ -155,7 +153,7 @@ describe("WebhookMatcherService datetime events", () => { executeWebhook .mockRejectedValueOnce(new Error("first endpoint failed")) .mockResolvedValueOnce(undefined) - webhookFindMany.mockResolvedValue([webhook, secondWebhook]) + listActiveWithConditions.mockResolvedValue([webhook, secondWebhook]) const matcher = new WebhookMatcherService() await expect( @@ -174,7 +172,7 @@ describe("WebhookMatcherService datetime events", () => { }) test("builds a matched event payload once for multiple webhook deliveries", async () => { - webhookFindMany.mockResolvedValue([webhook, secondWebhook]) + listActiveWithConditions.mockResolvedValue([webhook, secondWebhook]) const matcher = new WebhookMatcherService() await matcher.findAndExecuteWebhooks(datetimeEvent) @@ -197,7 +195,7 @@ describe("WebhookMatcherService datetime events", () => { // nothing has been sent yet, so the queue retry cannot duplicate a webhook. test("fails the job without delivering when the payload cannot be built", async () => { buildPayload.mockRejectedValue(new Error("contact lookup failed")) - webhookFindMany.mockResolvedValue([webhook, secondWebhook]) + listActiveWithConditions.mockResolvedValue([webhook, secondWebhook]) const matcher = new WebhookMatcherService() await expect(matcher.findAndExecuteWebhooks(datetimeEvent)).rejects.toThrow( @@ -211,10 +209,10 @@ describe("WebhookMatcherService datetime events", () => { // job, because matched webhooks are delivered after this step and a retry // would send them twice. It must still be visible in the logs. test("logs and skips a webhook whose conditions cannot be evaluated", async () => { - contactCustomFieldFindFirst.mockRejectedValue( + contactCustomFieldFindValue.mockRejectedValue( new Error("custom field lookup failed"), ) - webhookFindMany.mockResolvedValue([customFieldWebhook]) + listActiveWithConditions.mockResolvedValue([customFieldWebhook]) const matcher = new WebhookMatcherService() await expect( diff --git a/apps/worker/__tests__/webhook-payload-contact-inbox-leak.test.ts b/apps/worker/__tests__/webhook-payload-contact-inbox-leak.test.ts index 2a317aff02..46ba91c26b 100644 --- a/apps/worker/__tests__/webhook-payload-contact-inbox-leak.test.ts +++ b/apps/worker/__tests__/webhook-payload-contact-inbox-leak.test.ts @@ -26,21 +26,15 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ contactFindById: vi.fn(), listWithDefinitions: vi.fn(), - tagFindFirst: vi.fn(), + findNameByIdForWorkspace: vi.fn(), })) vi.mock("@chatbotx.io/business", () => ({ contactCustomFieldService: { listWithDefinitions: mocks.listWithDefinitions }, contactService: { findById: mocks.contactFindById }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - tagModel: { - findFirst: (...args: unknown[]) => mocks.tagFindFirst(...args), - }, - }, + tagService: { + findNameByIdForWorkspace: (...args: unknown[]) => + mocks.findNameByIdForWorkspace(...args), }, })) @@ -123,7 +117,7 @@ const SELECTIVELY_PROJECTED_EVENT_TYPES = Object.keys(EVENT_DATA_BY_TYPE) describe("buildWebhookPayload — contactInboxId never leaks (selectively-projecting builders)", () => { beforeEach(() => { vi.clearAllMocks() - mocks.tagFindFirst.mockResolvedValue({ name: "VIP" }) + mocks.findNameByIdForWorkspace.mockResolvedValue("VIP") mocks.contactFindById.mockResolvedValue({ id: "contact-1", fullName: "Ada Lovelace", diff --git a/apps/worker/src/default/handlers/export-contacts.ts b/apps/worker/src/default/handlers/export-contacts.ts index cf0ab5adc1..7e7ffc00c0 100644 --- a/apps/worker/src/default/handlers/export-contacts.ts +++ b/apps/worker/src/default/handlers/export-contacts.ts @@ -1,8 +1,11 @@ import type { PassThrough } from "node:stream" -import { workspaceService } from "@chatbotx.io/business" +import { + customFieldService, + tagService, + workspaceService, +} from "@chatbotx.io/business" import { auditService } from "@chatbotx.io/business/audit" import { normalizeStoredTimezone } from "@chatbotx.io/business/contact-locale" -import { and, db, eq } from "@chatbotx.io/database/client" import { type CustomFieldType, fileStatuses, @@ -12,9 +15,10 @@ import { pruneEmailPhoneFilterConditions, } from "@chatbotx.io/database/queries" import { - type contactCustomFieldModel, - fileModel, -} from "@chatbotx.io/database/schema" + contactRepository, + fileRepository, +} from "@chatbotx.io/database/repositories" +import type { contactCustomFieldModel } from "@chatbotx.io/database/schema" import { chunkById } from "@chatbotx.io/database/utils" import { createUpload } from "@chatbotx.io/filesystem/node-upload" import { SOURCE_USER_ID_EXPORT_HEADER } from "@chatbotx.io/imports/modules/contacts" @@ -278,10 +282,7 @@ const loadCustomFieldMap = async ( return {} } - const rows = await db.query.customFieldModel.findMany({ - where: { id: { in: ids }, workspaceId }, - columns: { id: true, name: true, type: true }, - }) + const rows = await customFieldService.findManyByIds({ workspaceId, ids }) return Object.fromEntries( rows.map((row) => [ @@ -309,13 +310,7 @@ export const buildSelectedFields = async ( const [tagNameById, customFieldById] = await Promise.all([ loadNameMap(idsOfType("tag"), (ids) => - db.query.tagModel.findMany({ - where: { - id: { in: ids }, - workspaceId, - deletedAt: { isNull: true as const }, - }, - }), + tagService.findManyByIds({ workspaceId, ids }), ), loadCustomFieldMap(idsOfType("custom"), workspaceId), ]) @@ -365,40 +360,22 @@ const fetchContactPage = ( lastId: string | null, options: { includeSourceUserId: boolean }, ) => - db.query.contactModel.findMany({ + contactRepository.listForExportPage({ where: lastId ? { AND: [baseWhere, { id: { gt: lastId } }] } : baseWhere, - with: { - contactCustomFields: true, - tags: true, - // The Contact Id column only needs the earliest row's sourceId. The - // WhatsApp User ID column must scan every inbox connection for the row - // that actually carries a sourceUserId (see `resolveSourceUserId`), so - // the earliest-row limit is lifted ONLY when that column is selected — - // ordinary exports keep the single-row load. - contactInboxes: { - columns: { sourceId: true, sourceUserId: true }, - orderBy: { id: "asc" }, - ...(options.includeSourceUserId ? {} : { limit: 1 }), - }, - }, limit: loopableItemsCount, - orderBy: { id: "asc" }, + includeSourceUserId: options.includeSourceUserId, }) /** Updates the export's File row, scoped to its workspace. */ const updateExportFile = ( ids: { fileId: string; workspaceId: string }, - values: Partial, + values: Parameters[0]["values"], ): Promise => - db - .update(fileModel) - .set(values) - .where( - and( - eq(fileModel.id, ids.fileId), - eq(fileModel.workspaceId, ids.workspaceId), - ), - ) + fileRepository.updateForWorkspace({ + id: ids.fileId, + workspaceId: ids.workspaceId, + values, + }) // H-3: Cap exports to avoid a single job monopolising a worker slot for hours // on large workspaces. Users who need more should use filtered exports or diff --git a/apps/worker/src/default/handlers/imports/handler/contacts/handler.ts b/apps/worker/src/default/handlers/imports/handler/contacts/handler.ts index 33dd5ba34b..39df236d52 100644 --- a/apps/worker/src/default/handlers/imports/handler/contacts/handler.ts +++ b/apps/worker/src/default/handlers/imports/handler/contacts/handler.ts @@ -1,28 +1,22 @@ import { botFieldService, - contactCustomFieldService, contactInboxService, - messageCleanupService, + contactService, + customFieldService, + inboxService, quotaEnforcementService, + tagService, workspaceService, workspaceUsageService, } from "@chatbotx.io/business" import { validateCustomFieldValue } from "@chatbotx.io/business/javascript-execution" -import { db, inArray } from "@chatbotx.io/database/client" -import { - type ContactImportFieldMapping, - type ContactImportMeta, - type CustomFieldType, - contactImportMetaSchema, - contactSources, +import type { + ContactImportFieldMapping, + ContactImportMeta, + CustomFieldType, } from "@chatbotx.io/database/partials" -import { - contactInboxModel, - contactModel, - contactsToTagsModel, - conversationModel, - type inboxModel, -} from "@chatbotx.io/database/schema" +import { contactImportMetaSchema } from "@chatbotx.io/database/partials" +import type { inboxModel } from "@chatbotx.io/database/schema" import { FieldReferenceKind, parseFieldReference, @@ -94,21 +88,18 @@ const prepareContacts = async ({ const [inbox, workspace, tag, fields, botFields] = await Promise.all([ row.inboxId - ? db.query.inboxModel.findFirst({ + ? inboxService.find({ where: { id: row.inboxId, workspaceId: row.workspaceId }, }) : null, workspaceService.find({ where: { id: row.workspaceId } }), meta.tagId - ? db.query.tagModel.findFirst({ - where: { id: meta.tagId, workspaceId: row.workspaceId }, - columns: { id: true }, - }) + ? tagService.findById({ workspaceId: row.workspaceId, id: meta.tagId }) : null, customFieldIds.length - ? db.query.customFieldModel.findMany({ - where: { id: { in: customFieldIds }, workspaceId: row.workspaceId }, - columns: { id: true, type: true }, + ? customFieldService.findManyByIds({ + workspaceId: row.workspaceId, + ids: customFieldIds, }) : [], // Batched once here (not per-row) so `processContactRow` can validate @@ -265,111 +256,22 @@ const insertContactBatch = async ( return 0 } - return db.transaction(async (tx) => { - await tx.insert(contactModel).values( - accepted.map(({ contactId, row }) => ({ - id: contactId, - workspaceId: ctx.row.workspaceId, - phoneNumber: row.phoneNumber, - email: row.email, - firstName: row.firstName, - lastName: row.lastName, - })), - ) - - // A duplicate should already have been removed by the re-check, but a - // non-import path (e.g. a concurrent inbound message creating the same - // (inboxId, sourceId)) can still win the race in the window between - // that re-check and this insert. `onConflictDoNothing` skips those rows; - // we then continue with only the contacts whose link actually inserted, - // so a single late conflict can no longer fail the entire batch while - // still guaranteeing no contact is created without its inbox row. - const insertedContactInboxes = await tx - .insert(contactInboxModel) - .values( - accepted.map(({ contactId, contactInboxId, row }) => { - // C-2: externalId is guaranteed non-null here by processContactBatch, - // but assert explicitly rather than casting to catch future regressions. - if (!row.externalId) { - throw new Error("Invariant: externalId must be set before insert") - } - return { - id: contactInboxId, - originalContactId: contactId, - contactId, - inboxId: deps.inbox.id, - channel: deps.inbox.channel, - source: contactSources.enum.imported, - sourceId: row.externalId, - sourceUserId: row.sourceUserId ?? null, - } - }), - ) - .onConflictDoNothing() - .returning({ contactId: contactInboxModel.contactId }) - - const insertedContactIds = new Set( - insertedContactInboxes.map((inboxRow) => inboxRow.contactId), - ) - const survivors = accepted.filter(({ contactId }) => - insertedContactIds.has(contactId), - ) - - // Re-created contacts keep their history: cancel any pending message - // cleanup recorded when contacts with these inbox identities were deleted. - await messageCleanupService.cancelByInboxSource({ - inboxId: deps.inbox.id, - sourceIds: survivors.flatMap(({ row }) => - row.externalId ? [row.externalId] : [], - ), - tx, - }) - - // Prune the orphan Contact rows whose link lost the conflict so we never - // leave a contact without a channel row (cascades clean up any partial - // children). - if (survivors.length !== accepted.length) { - const orphanIds = accepted - .filter(({ contactId }) => !insertedContactIds.has(contactId)) - .map(({ contactId }) => contactId) - await tx.delete(contactModel).where(inArray(contactModel.id, orphanIds)) - logger.warn( - { inboxId: deps.inbox.id, conflicts: orphanIds.length }, - "Import contact source conflict: skipped already-linked contacts", - ) - } - - if (survivors.length === 0) { - return 0 - } - - await tx.insert(conversationModel).values( - survivors.map(({ contactId }) => ({ - id: createId(), - workspaceId: ctx.row.workspaceId, - contactId, - })), - ) - - await contactCustomFieldService.insertNormalizedValuesForNewContacts({ + const { inserted, orphanCount } = + await contactService.insertImportedContactBatch({ workspaceId: ctx.row.workspaceId, - entries: survivors.map(({ contactId, row }) => ({ - contactId, - fields: row.customFields, - })), - tx, + inbox: { id: deps.inbox.id, channel: deps.inbox.channel }, + accepted, + tagId: ctx.meta.tagId, }) - if (ctx.meta.tagId) { - const tagId = ctx.meta.tagId - await tx - .insert(contactsToTagsModel) - .values(survivors.map(({ contactId }) => ({ contactId, tagId }))) - .onConflictDoNothing() - } + if (orphanCount > 0) { + logger.warn( + { inboxId: deps.inbox.id, conflicts: orphanCount }, + "Import contact source conflict: skipped already-linked contacts", + ) + } - return survivors.length - }) + return inserted } const processContactBatch = async ( diff --git a/apps/worker/src/default/handlers/sync-channel-labels.ts b/apps/worker/src/default/handlers/sync-channel-labels.ts index 52343cf919..50c3cf1cf0 100644 --- a/apps/worker/src/default/handlers/sync-channel-labels.ts +++ b/apps/worker/src/default/handlers/sync-channel-labels.ts @@ -1,13 +1,11 @@ -import { buildContext } from "@chatbotx.io/business" +import { buildContext, zaloIntegrationService } from "@chatbotx.io/business" import { logProviderError } from "@chatbotx.io/business/error-log" -import { db, isNull, sql } from "@chatbotx.io/database/client" import { type ChannelType, channelTypes } from "@chatbotx.io/database/partials" import { - contactsToTagsModel, - contactToTagChannelModel, - tagChannelModel, - tagModel, -} from "@chatbotx.io/database/schema" + contactInboxRepository, + integrationMessengerRepository, + tagChannelRepository, +} from "@chatbotx.io/database/repositories" import type { ContactInboxModel, IntegrationMessengerModel, @@ -18,7 +16,6 @@ import { integration as integrationMessenger } from "@chatbotx.io/integration-me import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/schema" import { integration as integrationZalo } from "@chatbotx.io/integration-zalo" import type { ZaloAuthValue } from "@chatbotx.io/integration-zalo/schema" -import { createId } from "@chatbotx.io/utils" import type { ErrorLogProvider } from "@chatbotx.io/utils/error-log" import type { JobSyncChannelLabels } from "@chatbotx.io/worker-config" import { logger } from "../../lib/logger" @@ -42,8 +39,8 @@ export async function handleSyncChannelLabels( const { workspaceId, channelType, integrationId } = data if (channelType === channelTypes.enum.messenger) { - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { id: integrationId }, + const integration = await integrationMessengerRepository.findById({ + id: integrationId, }) if (!integration) { @@ -55,8 +52,8 @@ export async function handleSyncChannelLabels( } if (channelType === channelTypes.enum.zalo) { - const integration = await db.query.integrationZaloModel.findFirst({ - where: { id: integrationId }, + const integration = await zaloIntegrationService.findByIdUnscoped({ + id: integrationId, }) if (!integration) { @@ -168,12 +165,9 @@ async function scanContactInboxes( await chunkById( (lastId) => - db.query.contactInboxModel.findMany({ - where: { - inboxId, - ...(lastId ? { id: { gt: lastId } } : {}), - }, - orderBy: { id: "asc" }, + contactInboxRepository.listByInboxPage({ + inboxId, + afterId: lastId ?? undefined, limit: BATCH_SIZE, }), { @@ -224,54 +218,14 @@ async function upsertLabelMapping(props: { label: NormalizedLabel contactInbox: ContactInboxModel }): Promise { - const { workspaceId, channelType, integrationId, label, contactInbox } = props - - const [tag] = await db - .insert(tagModel) - .values({ id: createId(), name: label.name, workspaceId }) - .onConflictDoUpdate({ - target: [tagModel.workspaceId, tagModel.name], - targetWhere: isNull(tagModel.deletedAt), - set: { name: sql`EXCLUDED.name` }, - }) - .returning({ id: tagModel.id }) - if (!tag) { - return - } - - const [tagChannel] = await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId, - tagId: tag.id, - channelType, - integrationId, - externalLabelId: label.externalLabelId, - }) - .onConflictDoUpdate({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - set: { externalLabelId: sql`EXCLUDED."externalLabelId"` }, - }) - .returning({ id: tagChannelModel.id }) - if (!tagChannel) { - return - } - - await db - .insert(contactsToTagsModel) - .values({ contactId: contactInbox.contactId, tagId: tag.id }) - .onConflictDoNothing() - await db - .insert(contactToTagChannelModel) - .values({ - tagId: tag.id, - tagChannelId: tagChannel.id, - contactInboxId: contactInbox.id, - }) - .onConflictDoNothing() + await tagChannelRepository.upsertLabelMapping({ + workspaceId: props.workspaceId, + channelType: props.channelType, + integrationId: props.integrationId, + label: props.label, + contactInbox: { + id: props.contactInbox.id, + contactId: props.contactInbox.contactId, + }, + }) } diff --git a/apps/worker/src/default/handlers/sync-tag.ts b/apps/worker/src/default/handlers/sync-tag.ts index 173fb12823..31a55dcb00 100644 --- a/apps/worker/src/default/handlers/sync-tag.ts +++ b/apps/worker/src/default/handlers/sync-tag.ts @@ -1,17 +1,18 @@ -import { buildContext } from "@chatbotx.io/business" +import { + buildContext, + tagService, + zaloIntegrationService, +} from "@chatbotx.io/business" import { logProviderError, logProviderErrorForChannel, } from "@chatbotx.io/business/error-log" -import { and, db, eq, inArray, isNotNull } from "@chatbotx.io/database/client" import { channelTypes } from "@chatbotx.io/database/partials" import { - contactInboxModel, - contactsToTagsModel, - contactToTagChannelModel, - tagChannelModel, - tagModel, -} from "@chatbotx.io/database/schema" + contactInboxRepository, + integrationMessengerRepository, + tagChannelRepository, +} from "@chatbotx.io/database/repositories" import type { ContactInboxModel, IntegrationMessengerModel, @@ -23,7 +24,6 @@ import type { MessengerAuthValue } from "@chatbotx.io/integration-messenger/sche import { integration as integrationZalo } from "@chatbotx.io/integration-zalo" import type { ZaloAuthValue } from "@chatbotx.io/integration-zalo/schema" import { distributedLock } from "@chatbotx.io/redis" -import { createId } from "@chatbotx.io/utils" import type { JobSyncTag } from "@chatbotx.io/worker-config" import { logger } from "../../lib/logger" @@ -31,6 +31,14 @@ const DELETE_CHUNK_SIZE = 500 type TagWithName = { id: string; name: string; workspaceId: string } +// This handler calls tagChannelRepository / contactInboxRepository / +// integrationMessengerRepository methods directly rather than through a +// service. These are named, documented, tx-accepting repository methods with +// no cache/event/validation logic of their own (moved verbatim off inline +// db.* calls) — matching the existing main pattern in export-coupons.ts and +// send-messenger-template.ts. Deliberate choice, not an oversight; see PR +// #1101 review, A6. + /** * Single entry point for every tag-sync job. The `action` discriminator selects * the operation: create the channel labels for a new tag, attach/detach a tag @@ -65,10 +73,7 @@ async function syncTagCreate(props: { }): Promise { const { workspaceId, tagId } = props - const tag = await db.query.tagModel.findFirst({ - where: { id: tagId, workspaceId }, - columns: { id: true, name: true }, - }) + const tag = await tagService.findById({ workspaceId, id: tagId }) if (!tag) { logger.warn({ tagId }, "syncTag(create): tag missing") return @@ -76,9 +81,7 @@ async function syncTagCreate(props: { // Messenger: create the page-level Custom Label on every enabled page. const messengerIntegrations = - await db.query.integrationMessengerModel.findMany({ - where: { workspaceId }, - }) + await integrationMessengerRepository.listByWorkspace({ workspaceId }) for (const integration of messengerIntegrations) { if (!integration.syncTagEnabledAt) { continue @@ -104,30 +107,20 @@ async function syncTagCreate(props: { // Zalo: there is no create-empty-tag API — tags materialize on the first // tagfollower call. Record the mapping (name-based) on every enabled OA so // future assignments + reconciliation resolve correctly; no API call here. - const zaloIntegrations = await db.query.integrationZaloModel.findMany({ - where: { workspaceId }, + const zaloIntegrations = await zaloIntegrationService.listByWorkspace({ + workspaceId, }) for (const integration of zaloIntegrations) { if (!integration.syncTagEnabledAt) { continue } - await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId, - tagId: tag.id, - channelType: channelTypes.enum.zalo, - integrationId: integration.id, - externalLabelId: tag.name, - }) - .onConflictDoNothing({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - }) + await tagChannelRepository.insertIfAbsent({ + workspaceId, + tagId: tag.id, + channelType: channelTypes.enum.zalo, + integrationId: integration.id, + externalLabelId: tag.name, + }) } } @@ -146,14 +139,11 @@ async function createMessengerLabel(props: { key: lockKey, timeoutInSeconds: 30, fn: async () => { - const existing = await db.query.tagChannelModel.findFirst({ - where: { - tagId: tag.id, - workspaceId, - channelType: channelTypes.enum.messenger, - integrationId: integration.id, - }, - columns: { id: true }, + const existing = await tagChannelRepository.findByTagAndIntegration({ + tagId: tag.id, + workspaceId, + channelType: channelTypes.enum.messenger, + integrationId: integration.id, }) const { id: externalLabelId } = @@ -163,30 +153,20 @@ async function createMessengerLabel(props: { }) if (existing) { - await db - .update(tagChannelModel) - .set({ externalLabelId }) - .where(eq(tagChannelModel.id, existing.id)) + await tagChannelRepository.updateExternalLabelId({ + id: existing.id, + externalLabelId, + }) return } - await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId, - tagId: tag.id, - channelType: channelTypes.enum.messenger, - integrationId: integration.id, - externalLabelId, - }) - .onConflictDoNothing({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - }) + await tagChannelRepository.insertIfAbsent({ + workspaceId, + tagId: tag.id, + channelType: channelTypes.enum.messenger, + integrationId: integration.id, + externalLabelId, + }) }, }) } @@ -202,17 +182,14 @@ async function syncTagAttach(props: { }): Promise { const { workspaceId, contactId, tagId } = props - const tag = await db.query.tagModel.findFirst({ - where: { id: tagId, workspaceId }, - columns: { id: true, name: true, workspaceId: true }, - }) + const tag = await tagService.findById({ workspaceId, id: tagId }) if (!tag) { logger.warn({ tagId }, "syncTag(attach): tag missing") return } - const contactInboxes = await db.query.contactInboxModel.findMany({ - where: { contactId }, + const contactInboxes = await contactInboxRepository.listByContactId({ + contactId, }) for (const contactInbox of contactInboxes) { @@ -230,8 +207,8 @@ async function attachOnMessenger(props: { contactInbox: ContactInboxModel }): Promise { const { workspaceId, tag, contactInbox } = props - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { inboxId: contactInbox.inboxId }, + const integration = await integrationMessengerRepository.findByInboxId({ + inboxId: contactInbox.inboxId, }) if (!integration?.syncTagEnabledAt) { return @@ -244,13 +221,11 @@ async function attachOnMessenger(props: { key: lockKey, timeoutInSeconds: 30, fn: async () => { - const existing = await db.query.tagChannelModel.findFirst({ - where: { - tagId: tag.id, - workspaceId, - channelType: channelTypes.enum.messenger, - integrationId: integration.id, - }, + const existing = await tagChannelRepository.findByTagAndIntegration({ + tagId: tag.id, + workspaceId, + channelType: channelTypes.enum.messenger, + integrationId: integration.id, }) if (existing) { return existing @@ -262,34 +237,12 @@ async function attachOnMessenger(props: { data: { pageId: integration.pageId, name: tag.name }, }) - const inserted = await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId, - tagId: tag.id, - channelType: channelTypes.enum.messenger, - integrationId: integration.id, - externalLabelId, - }) - .onConflictDoNothing({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - }) - .returning() - if (inserted[0]) { - return inserted[0] - } - return await db.query.tagChannelModel.findFirst({ - where: { - tagId: tag.id, - workspaceId, - channelType: channelTypes.enum.messenger, - integrationId: integration.id, - }, + return await tagChannelRepository.insertOrFetch({ + workspaceId, + tagId: tag.id, + channelType: channelTypes.enum.messenger, + integrationId: integration.id, + externalLabelId, }) }, }) @@ -310,14 +263,11 @@ async function attachOnMessenger(props: { }, }) - await db - .insert(contactToTagChannelModel) - .values({ - tagId: tag.id, - tagChannelId: tagChannel.id, - contactInboxId: contactInbox.id, - }) - .onConflictDoNothing() + await tagChannelRepository.linkContactInbox({ + tagId: tag.id, + tagChannelId: tagChannel.id, + contactInboxId: contactInbox.id, + }) } async function attachOnZalo(props: { @@ -326,8 +276,8 @@ async function attachOnZalo(props: { contactInbox: ContactInboxModel }): Promise { const { workspaceId, tag, contactInbox } = props - const integration = await db.query.integrationZaloModel.findFirst({ - where: { inboxId: contactInbox.inboxId }, + const integration = await zaloIntegrationService.findByInboxId({ + inboxId: contactInbox.inboxId, }) if (!integration?.syncTagEnabledAt) { return @@ -341,38 +291,23 @@ async function attachOnZalo(props: { tagName: tag.name, }) - const [tagChannel] = await db - .insert(tagChannelModel) - .values({ - id: createId(), - workspaceId, - tagId: tag.id, - channelType: channelTypes.enum.zalo, - integrationId: integration.id, - externalLabelId: tag.name, - }) - .onConflictDoUpdate({ - target: [ - tagChannelModel.tagId, - tagChannelModel.channelType, - tagChannelModel.integrationId, - ], - set: { externalLabelId: tag.name }, - }) - .returning() + const tagChannel = await tagChannelRepository.upsertByTagAndIntegration({ + workspaceId, + tagId: tag.id, + channelType: channelTypes.enum.zalo, + integrationId: integration.id, + externalLabelId: tag.name, + }) if (!tagChannel) { return } - await db - .insert(contactToTagChannelModel) - .values({ - tagId: tag.id, - tagChannelId: tagChannel.id, - contactInboxId: contactInbox.id, - }) - .onConflictDoNothing() + await tagChannelRepository.linkContactInbox({ + tagId: tag.id, + tagChannelId: tagChannel.id, + contactInboxId: contactInbox.id, + }) } // --------------------------------------------------------------------------- @@ -387,30 +322,10 @@ async function syncTagDetach(props: { }): Promise { const { workspaceId, contactId, tagId } = props - const rows = await db - .select({ - tagChannelId: contactToTagChannelModel.tagChannelId, - contactInboxId: contactToTagChannelModel.contactInboxId, - channelType: tagChannelModel.channelType, - integrationId: tagChannelModel.integrationId, - externalLabelId: tagChannelModel.externalLabelId, - sourceId: contactInboxModel.sourceId, - }) - .from(contactToTagChannelModel) - .innerJoin( - tagChannelModel, - eq(contactToTagChannelModel.tagChannelId, tagChannelModel.id), - ) - .innerJoin( - contactInboxModel, - eq(contactToTagChannelModel.contactInboxId, contactInboxModel.id), - ) - .where( - and( - eq(contactToTagChannelModel.tagId, tagId), - eq(contactInboxModel.contactId, contactId), - ), - ) + const rows = await tagChannelRepository.listContactTagChannelRows({ + tagId, + contactId, + }) for (const row of rows) { try { @@ -428,14 +343,10 @@ async function syncTagDetach(props: { }) } // Delete the local mapping regardless of sync state / API outcome. - await db - .delete(contactToTagChannelModel) - .where( - and( - eq(contactToTagChannelModel.tagChannelId, row.tagChannelId), - eq(contactToTagChannelModel.contactInboxId, row.contactInboxId), - ), - ) + await tagChannelRepository.unlinkContactInbox({ + tagChannelId: row.tagChannelId, + contactInboxId: row.contactInboxId, + }) } } @@ -494,14 +405,11 @@ async function syncTagDelete(props: JobSyncTagDelete): Promise { // workspace Tag and every other channel intact. The channel already removed // the label, so we do NOT call its API again (callApi: false). if (channelType && integrationId) { - const channels = await db.query.tagChannelModel.findMany({ - where: { tagId, workspaceId, channelType, integrationId }, - columns: { - id: true, - channelType: true, - integrationId: true, - externalLabelId: true, - }, + const channels = await tagChannelRepository.listByTag({ + workspaceId, + tagId, + channelType, + integrationId, }) for (const channel of channels) { await deleteTagOnChannel({ workspaceId, tagId, channel, callApi: false }) @@ -555,15 +463,11 @@ async function deleteTagOnChannel(props: { // channel rows + the workspace tag for those contacts as we go. await chunkById( (lastId) => - db.query.contactToTagChannelModel - .findMany({ - where: { - tagChannelId: { in: [channel.id] }, - ...(lastId ? { contactInboxId: { gt: lastId } } : {}), - }, - orderBy: { contactInboxId: "asc" }, + tagChannelRepository + .listContactInboxIdsForChannelPage({ + tagChannelId: channel.id, + afterContactInboxId: lastId ?? undefined, limit: DELETE_CHUNK_SIZE, - columns: { contactInboxId: true }, }) .then((rows) => rows.map((row) => ({ id: row.contactInboxId }))), { @@ -571,37 +475,28 @@ async function deleteTagOnChannel(props: { callback: async (batch) => { const contactInboxIds = batch.map((row) => row.id) - const inboxes = await db.query.contactInboxModel.findMany({ - where: { id: { in: contactInboxIds } }, - columns: { contactId: true }, + const inboxes = await contactInboxRepository.listContactIdsByIds({ + ids: contactInboxIds, }) const contactIds = [...new Set(inboxes.map((inbox) => inbox.contactId))] - await db - .delete(contactToTagChannelModel) - .where( - and( - eq(contactToTagChannelModel.tagChannelId, channel.id), - inArray(contactToTagChannelModel.contactInboxId, contactInboxIds), - ), - ) + await tagChannelRepository.deleteLinksForChannel({ + tagChannelId: channel.id, + contactInboxIds, + }) if (contactIds.length > 0) { - await db - .delete(contactsToTagsModel) - .where( - and( - eq(contactsToTagsModel.tagId, tagId), - inArray(contactsToTagsModel.contactId, contactIds), - ), - ) + await tagChannelRepository.deleteContactTagsForContacts({ + tagId, + contactIds, + }) } return true }, }, ) - await db.delete(tagChannelModel).where(eq(tagChannelModel.id, channel.id)) + await tagChannelRepository.deleteById({ id: channel.id }) } /** @@ -614,15 +509,7 @@ async function deleteTagOnChannels(props: { }): Promise { const { workspaceId, tagId } = props - const channels = await db.query.tagChannelModel.findMany({ - where: { tagId, workspaceId }, - columns: { - id: true, - channelType: true, - integrationId: true, - externalLabelId: true, - }, - }) + const channels = await tagChannelRepository.listByTag({ workspaceId, tagId }) // Isolate per-channel failures so one bad channel can't block the others or // the final Tag delete. @@ -643,43 +530,27 @@ async function deleteTagOnChannels(props: { // has a composite PK (no `id`), so page by contactId. await chunkById( (lastId) => - db.query.contactsToTagsModel - .findMany({ - where: { - tagId, - ...(lastId ? { contactId: { gt: lastId } } : {}), - }, - orderBy: { contactId: "asc" }, + tagChannelRepository + .listTaggedContactIdsPage({ + tagId, + afterContactId: lastId ?? undefined, limit: DELETE_CHUNK_SIZE, - columns: { contactId: true }, }) .then((rows) => rows.map((row) => ({ id: row.contactId }))), { chunkSize: DELETE_CHUNK_SIZE, callback: async (batch) => { const contactIds = batch.map((row) => row.id) - await db - .delete(contactsToTagsModel) - .where( - and( - eq(contactsToTagsModel.tagId, tagId), - inArray(contactsToTagsModel.contactId, contactIds), - ), - ) + await tagChannelRepository.deleteContactTagsForContacts({ + tagId, + contactIds, + }) return true }, }, ) - await db - .delete(tagModel) - .where( - and( - eq(tagModel.id, tagId), - eq(tagModel.workspaceId, workspaceId), - isNotNull(tagModel.deletedAt), - ), - ) + await tagService.hardDeleteSoftDeleted({ workspaceId, tagId }) } async function deleteLabelOnChannel(props: { @@ -761,8 +632,8 @@ async function getMessengerSyncContext(props: { workspaceId: string integrationId: string }) { - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { id: props.integrationId }, + const integration = await integrationMessengerRepository.findById({ + id: props.integrationId, }) if (!integration?.syncTagEnabledAt) { return null @@ -778,8 +649,8 @@ async function getZaloSyncContext(props: { workspaceId: string integrationId: string }) { - const integration = await db.query.integrationZaloModel.findFirst({ - where: { id: props.integrationId }, + const integration = await zaloIntegrationService.findByIdUnscoped({ + id: props.integrationId, }) if (!integration?.syncTagEnabledAt) { return null diff --git a/apps/worker/src/schedule/handlers/enqueue-broadcast.ts b/apps/worker/src/schedule/handlers/enqueue-broadcast.ts index fa2ef6c87a..3731cebc27 100644 --- a/apps/worker/src/schedule/handlers/enqueue-broadcast.ts +++ b/apps/worker/src/schedule/handlers/enqueue-broadcast.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { broadcastService } from "@chatbotx.io/business" import { ScheduleJobData, scheduleQueue } from "@chatbotx.io/worker-config" import { startOfMinute } from "date-fns" @@ -6,14 +6,8 @@ const ENQUEUE_BULK_SIZE = 500 export const enqueueBroadcast = async () => { const startTime = startOfMinute(new Date().toString()) - const broadcasts = await db.query.broadcastModel.findMany({ - where: { - schedulesAt: { - lte: startTime, - }, - status: "scheduled", - deletedAt: { isNull: true }, - }, + const broadcasts = await broadcastService.listDueScheduled({ + dueAt: startTime, }) if (broadcasts.length === 0) { diff --git a/apps/worker/src/schedule/handlers/maintain-mac-partitions.ts b/apps/worker/src/schedule/handlers/maintain-mac-partitions.ts index 64601d9354..822c360224 100644 --- a/apps/worker/src/schedule/handlers/maintain-mac-partitions.ts +++ b/apps/worker/src/schedule/handlers/maintain-mac-partitions.ts @@ -1,4 +1,8 @@ -import { db, sql } from "@chatbotx.io/database/client" +import { + addUtcMonths, + createContactActiveHourlyPartition, + createContactActiveMonthlyPartition, +} from "@chatbotx.io/database/repositories" import { logger } from "../../lib/logger" // Keeps the MAC partition trees ahead of incoming data. `ContactActiveMonthly` @@ -9,62 +13,6 @@ const CONFIG = { yearlyPartitionsAhead: 1, } as const -async function partitionExists(name: string): Promise { - const result = await db.execute<{ exists: boolean }>(sql` - SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = ${name}) AS "exists" - `) - return result.rows[0]?.exists ?? false -} - -async function createYearlyPartition(year: number): Promise { - const name = `ContactActiveMonthly_${year}` - if (await partitionExists(name)) { - return false - } - - await db.execute(sql` - CREATE TABLE IF NOT EXISTS ${sql.identifier(name)} - PARTITION OF "ContactActiveMonthly" - FOR VALUES FROM (${sql.raw(`'${year}-01-01'`)}) TO (${sql.raw(`'${year + 1}-01-01'`)}) - `) - return true -} - -function addUtcMonths(date: Date, months: number): Date { - return new Date( - Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1), - ) -} - -function formatMonthlyPartitionName(date: Date): string { - const year = date.getUTCFullYear() - const month = String(date.getUTCMonth() + 1).padStart(2, "0") - return `ContactActiveHourly_${year}_${month}` -} - -function formatUtcDate(date: Date): string { - const year = date.getUTCFullYear() - const month = String(date.getUTCMonth() + 1).padStart(2, "0") - return `${year}-${month}-01` -} - -async function createHourlyMonthlyPartition( - monthStart: Date, -): Promise { - const name = formatMonthlyPartitionName(monthStart) - if (await partitionExists(name)) { - return false - } - - const nextMonth = addUtcMonths(monthStart, 1) - await db.execute(sql` - CREATE TABLE IF NOT EXISTS ${sql.identifier(name)} - PARTITION OF "ContactActiveHourly" - FOR VALUES FROM (${sql.raw(`'${formatUtcDate(monthStart)}'`)}) TO (${sql.raw(`'${formatUtcDate(nextMonth)}'`)}) - `) - return true -} - export async function maintainMacPartitions(): Promise { const now = new Date() const currentMonth = new Date( @@ -75,13 +23,15 @@ export async function maintainMacPartitions(): Promise { try { for (let i = 0; i <= CONFIG.yearlyPartitionsAhead; i++) { - if (await createYearlyPartition(now.getUTCFullYear() + i)) { + if (await createContactActiveMonthlyPartition(now.getUTCFullYear() + i)) { createdYearly++ } } for (let i = 0; i <= CONFIG.hourlyMonthsAhead; i++) { - if (await createHourlyMonthlyPartition(addUtcMonths(currentMonth, i))) { + if ( + await createContactActiveHourlyPartition(addUtcMonths(currentMonth, i)) + ) { createdHourly++ } } diff --git a/apps/worker/src/schedule/handlers/prepare-broadcast.ts b/apps/worker/src/schedule/handlers/prepare-broadcast.ts index c814cd9b88..a4a0df5740 100644 --- a/apps/worker/src/schedule/handlers/prepare-broadcast.ts +++ b/apps/worker/src/schedule/handlers/prepare-broadcast.ts @@ -1,5 +1,4 @@ import { broadcastService, conversationService } from "@chatbotx.io/business" -import { and, db, eq, isNull } from "@chatbotx.io/database/client" import { type BroadcastStatus, broadcastStatuses, @@ -9,10 +8,6 @@ import { } from "@chatbotx.io/database/partials" import type { ContactFilterCriteriaInput } from "@chatbotx.io/database/queries" import { purgeBroadcastRecipients } from "@chatbotx.io/database/repositories" -import { - broadcastModel, - contactsOnBroadcastsModel, -} from "@chatbotx.io/database/schema" import { broadcastSendJobId, ScheduleJobData, @@ -28,13 +23,8 @@ const PREPARE_PURGE_INTER_CHUNK_DELAY_MS = 50 const PREPARE_PURGE_MAX_RUN_DURATION_MS = 60_000 export const prepareBroadcast = async (broadcastId: string) => { - const broadcast = await db.query.broadcastModel.findFirst({ - where: { - id: broadcastId, - status: "scheduled", - deletedAt: { isNull: true }, - }, - with: { targets: { columns: { inboxId: true } } }, + const broadcast = await broadcastService.findScheduledForPrepare({ + broadcastId, }) if (!broadcast) { @@ -78,14 +68,11 @@ export const prepareBroadcast = async (broadcastId: string) => { broadcastSubactions.enum.messengerTemplateMessage && broadcast.templateId ) { - const template = await db.query.messengerMessageTemplateModel.findFirst({ - where: { - id: broadcast.templateId, - integrationMessenger: { workspaceId: broadcast.workspaceId }, - }, - columns: { integrationMessengerId: true }, - }) - integrationMessengerId = template?.integrationMessengerId ?? null + integrationMessengerId = + await broadcastService.resolveTemplateIntegrationMessengerId({ + workspaceId: broadcast.workspaceId, + templateId: broadcast.templateId, + }) } let hasContactOnBroadcast = false @@ -153,10 +140,7 @@ export const prepareBroadcast = async (broadcastId: string) => { hasContactOnBroadcast = true - await db - .insert(contactsOnBroadcastsModel) - .values(recipients) - .onConflictDoNothing() + await broadcastService.insertRecipients({ recipients }) contactCount += recipients.length @@ -168,18 +152,12 @@ export const prepareBroadcast = async (broadcastId: string) => { ? broadcastStatuses.enum.sending : broadcastStatuses.enum.sent - const [promoted] = await db - .update(broadcastModel) - .set({ status: broadcastStatus, contactCount }) - .where( - and( - eq(broadcastModel.id, broadcastId), - eq(broadcastModel.status, broadcastStatuses.enum.scheduled), - isNull(broadcastModel.deletedAt), - eq(broadcastModel.resumeCount, promotionEpoch), - ), - ) - .returning({ id: broadcastModel.id }) + const promoted = await broadcastService.promoteAfterPrepare({ + broadcastId, + status: broadcastStatus, + contactCount, + promotionEpoch, + }) if (!promoted) { // Lost the promotion race — a moveToDraft (or delete) bumped the epoch diff --git a/apps/worker/src/schedule/handlers/process-broadcast-contacts.ts b/apps/worker/src/schedule/handlers/process-broadcast-contacts.ts index d6b305acaf..19a692fbf1 100644 --- a/apps/worker/src/schedule/handlers/process-broadcast-contacts.ts +++ b/apps/worker/src/schedule/handlers/process-broadcast-contacts.ts @@ -1,16 +1,17 @@ -import { broadcastService } from "@chatbotx.io/business" -import { and, db, eq, sql } from "@chatbotx.io/database/client" +import { + type BroadcastForSend, + type BroadcastRecipientForSend, + broadcastService, +} from "@chatbotx.io/business" import { broadcastSendsFlow, broadcastSendsTemplate, - broadcastStatuses, channelTypes, hasBroadcastSendForInbox, resolveBroadcastFlowSend, resolveBroadcastTemplateSend, usesBroadcastTargets, } from "@chatbotx.io/database/partials" -import { contactsOnBroadcastsModel } from "@chatbotx.io/database/schema" import type { ContactInboxModel, ConversationModel, @@ -32,17 +33,6 @@ import { logger } from "../../lib/logger" const DEFAULT_BROADCAST_RATE_LIMIT = 500 const BROADCAST_SEND_JOB_RETENTION_SECONDS = 3600 -type BroadcastForSend = Awaited< - ReturnType<(typeof db.query.broadcastModel)["findMany"]> ->[number] & { - targets: { - inboxId: string - flowId: string | null - templateId: string | null - templateData: unknown - }[] -} - /** The reasons a recipient cannot be enqueued; stored as the row's `errorContent`. */ const NO_TEMPLATE_FOR_PAGE_REASON = "no template selected for the contact's page" @@ -50,12 +40,7 @@ const NO_FLOW_FOR_PAGE_REASON = "no flow selected for the contact's page" const NO_SEND_FOR_PAGE_REASON = "no flow or template selected for the contact's page" -type ContactOnBroadcastForSend = Awaited< - ReturnType<(typeof db.query.contactsOnBroadcastsModel)["findMany"]> ->[number] & { - conversation?: ConversationModel | null - contactInbox?: ContactInboxModel | null -} +type ContactOnBroadcastForSend = BroadcastRecipientForSend const downstreamJobOptions = (jobId: string) => ({ jobId, @@ -149,21 +134,11 @@ const markContactFailed = async ( contactOnBroadcast: ContactOnBroadcastForSend, reason: string, ) => { - await db - .update(contactsOnBroadcastsModel) - .set({ - failedAt: sql`CURRENT_TIMESTAMP`, - errorContent: reason, - }) - .where( - and( - eq( - contactsOnBroadcastsModel.broadcastId, - contactOnBroadcast.broadcastId, - ), - eq(contactsOnBroadcastsModel.contactId, contactOnBroadcast.contactId), - ), - ) + await broadcastService.markContactFailed({ + broadcastId: contactOnBroadcast.broadcastId, + contactId: contactOnBroadcast.contactId, + reason, + }) } const enqueueBroadcastContact = async ( @@ -287,23 +262,7 @@ const enqueueBroadcastContact = async ( } export const processBroadcastContacts = async (broadcastId: string) => { - const broadcasts = await db.query.broadcastModel.findMany({ - where: { - id: broadcastId, - status: broadcastStatuses.enum.sending, - deletedAt: { isNull: true }, - }, - with: { - targets: { - columns: { - inboxId: true, - flowId: true, - templateId: true, - templateData: true, - }, - }, - }, - }) + const broadcasts = await broadcastService.listSendableById({ broadcastId }) if (broadcasts.length === 0) { return { processed: 0 } @@ -316,19 +275,10 @@ export const processBroadcastContacts = async (broadcastId: string) => { let totalProcessed = 0 for (const broadcast of broadcasts) { - const contactsOnBroadcasts = - await db.query.contactsOnBroadcastsModel.findMany({ - where: { - broadcastId: broadcast.id, - sent: false, - failedAt: { isNull: true }, - }, - with: { - conversation: true, - contactInbox: true, - }, - limit: DEFAULT_BROADCAST_RATE_LIMIT, - }) + const contactsOnBroadcasts = await broadcastService.listPendingRecipients({ + broadcastId: broadcast.id, + limit: DEFAULT_BROADCAST_RATE_LIMIT, + }) if (contactsOnBroadcasts.length === 0) { // Everything has been handed to the channel; finalizeBroadcasts resolves sent|failed. diff --git a/apps/worker/src/schedule/handlers/reconcile-broadcasts.ts b/apps/worker/src/schedule/handlers/reconcile-broadcasts.ts index 6c366b3bda..6538dbb6a8 100644 --- a/apps/worker/src/schedule/handlers/reconcile-broadcasts.ts +++ b/apps/worker/src/schedule/handlers/reconcile-broadcasts.ts @@ -1,5 +1,4 @@ -import { db } from "@chatbotx.io/database/client" -import { broadcastStatuses } from "@chatbotx.io/database/partials" +import { broadcastService } from "@chatbotx.io/business" import { distributedLock } from "@chatbotx.io/redis" import { broadcastSendJobId, @@ -15,13 +14,7 @@ export const reconcileBroadcasts = async () => key: LOCK_KEY, timeoutInSeconds: LOCK_TTL_SECONDS, fn: async () => { - const broadcasts = await db.query.broadcastModel.findMany({ - where: { - status: broadcastStatuses.enum.sending, - handoffCompletedAt: { isNull: true }, - deletedAt: { isNull: true }, - }, - }) + const broadcasts = await broadcastService.listSendingAwaitingHandoff() for (const broadcast of broadcasts) { await scheduleQueue.add( diff --git a/apps/worker/src/schedule/handlers/sync-user-quota.ts b/apps/worker/src/schedule/handlers/sync-user-quota.ts index c01b9220bf..2e05e8f532 100644 --- a/apps/worker/src/schedule/handlers/sync-user-quota.ts +++ b/apps/worker/src/schedule/handlers/sync-user-quota.ts @@ -7,27 +7,7 @@ import { WORKSPACE_USAGE_LABEL, workspaceUsageService, } from "@chatbotx.io/business" -// NOTE: this handler is only partially migrated to the service layer — the -// ghost-id existence check now goes through `userService.listExistingIds`, -// but the reconcile/count/upsert queries below still use `db` directly. -// That's an intentional legacy exception (see `.agents/rules/data-access.md` -// § "Existing exceptions"), not an inconsistency to "fix" incidentally — -// migrating the rest is separate scope. -import { - count, - db, - eq, - isForeignKeyViolationError, - sql, -} from "@chatbotx.io/database/client" -import { - contactModel, - inboxModel, - userQuotaModel, - workspaceMemberModel, - workspaceModel, - workspaceUsageModel, -} from "@chatbotx.io/database/schema" +import { isForeignKeyViolationError } from "@chatbotx.io/database/client" import { cacheConnections } from "@chatbotx.io/redis" import { liveKeyFor, USER_QUOTA_LABEL } from "@chatbotx.io/utils" import { logger } from "../../lib/logger" @@ -123,68 +103,36 @@ export const reconcileWorkspaceUsage = async ( client: CacheClient, ): Promise => { try { - const [workspaces, contactCounts, channelCounts, memberCounts, macCounts] = - await Promise.all([ - db.select({ id: workspaceModel.id }).from(workspaceModel), - db - .select({ workspaceId: contactModel.workspaceId, used: count() }) - .from(contactModel) - .groupBy(contactModel.workspaceId), - db - .select({ workspaceId: inboxModel.workspaceId, used: count() }) - .from(inboxModel) - .groupBy(inboxModel.workspaceId), - db - .select({ - workspaceId: workspaceMemberModel.workspaceId, - used: count(), - }) - .from(workspaceMemberModel) - .groupBy(workspaceMemberModel.workspaceId), - macRepository.getActiveContactCountsByWorkspaceIds(), - ]) - - const contactsByWorkspace = new Map( - contactCounts.map((row) => [row.workspaceId, row.used]), - ) - const channelsByWorkspace = new Map( - channelCounts.map((row) => [row.workspaceId, row.used]), - ) - const membersByWorkspace = new Map( - memberCounts.map((row) => [row.workspaceId, row.used]), - ) + const [ + { + workspaceIds, + contactsByWorkspace, + channelsByWorkspace, + membersByWorkspace, + }, + macCounts, + ] = await Promise.all([ + workspaceUsageService.loadReconcileCounts(), + macRepository.getActiveContactCountsByWorkspaceIds(), + ]) const BATCH_SIZE = 50 - for (let i = 0; i < workspaces.length; i += BATCH_SIZE) { - const batch = workspaces.slice(i, i + BATCH_SIZE) + for (let i = 0; i < workspaceIds.length; i += BATCH_SIZE) { + const batch = workspaceIds.slice(i, i + BATCH_SIZE) await Promise.all( - batch.map(async ({ id: workspaceId }) => { + batch.map(async (workspaceId) => { const contactsUsed = contactsByWorkspace.get(workspaceId) ?? 0 const channelsUsed = channelsByWorkspace.get(workspaceId) ?? 0 const teamMembersUsed = membersByWorkspace.get(workspaceId) ?? 0 const macUsed = macCounts.get(workspaceId) ?? 0 - await db - .insert(workspaceUsageModel) - .values({ - workspaceId, - contactsUsed, - channelsUsed, - teamMembersUsed, - macUsed, - syncedAt: new Date(), - }) - .onConflictDoUpdate({ - target: workspaceUsageModel.workspaceId, - set: { - contactsUsed, - channelsUsed, - teamMembersUsed, - macUsed, - syncedAt: new Date(), - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }) + await workspaceUsageService.upsertReconciled({ + workspaceId, + contactsUsed, + channelsUsed, + teamMembersUsed, + macUsed, + }) await client.hset( liveKeyFor(WORKSPACE_USAGE_LABEL, workspaceId), @@ -221,109 +169,25 @@ export const reconcileUser = async (userId: string): Promise => { const client = await cacheConnections.useExisting() - const [ - [contactsResult], - teamMembersUsed, - [workspacesResult], - [channelsResult], - ] = await Promise.all([ - db - .select({ count: count() }) - .from(contactModel) - .innerJoin( - workspaceModel, - eq(contactModel.workspaceId, workspaceModel.id), - ) - .where(eq(workspaceModel.ownerId, userId)), - - userQuotaService.countDistinctTeamMembersForOwner(userId), - - db - .select({ count: count() }) - .from(workspaceModel) - .where(eq(workspaceModel.ownerId, userId)), - - db - .select({ count: count() }) - .from(inboxModel) - .innerJoin( - workspaceModel, - eq(inboxModel.workspaceId, workspaceModel.id), - ) - .where(eq(workspaceModel.ownerId, userId)), - ]) - - const contactsUsed = contactsResult?.count ?? 0 - const workspacesUsed = workspacesResult?.count ?? 0 - const channelsUsed = channelsResult?.count ?? 0 - - await db - .insert(userQuotaModel) - .values({ - userId, - contactsUsed, - teamMembersUsed, - workspacesUsed, - channelsUsed, - syncedAt: new Date(), - }) - .onConflictDoUpdate({ - target: userQuotaModel.userId, - set: { - // Authoritative current count from the source tables (already reflects - // deletions). Assigned directly — NOT GREATEST — so removing contacts, - // team members, workspaces, or channels frees quota. A transiently-low - // COUNT racing an in-flight insert self-corrects on the next sync; that - // brief window is far better than a high-water max that never decreases. - contactsUsed, - teamMembersUsed, - workspacesUsed, - channelsUsed, - syncedAt: new Date(), - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }) - - // Mirror the live counters to the same authoritative current counts. - await client.hset( - liveKeyFor(USER_QUOTA_LABEL, userId), - "contacts", - String(contactsUsed), - "teamMembers", - String(teamMembersUsed), - "workspaces", - String(workspacesUsed), - "channels", - String(channelsUsed), - ) - - // mac is monotonic-within-period and lives only on the quota row, so read it - // back (alongside the billing period) for the separate mac reconcile. - // `periodEnd === null` marks a lifetime plan, which never resets. - const stored = await db.query.userQuotaModel.findFirst({ - where: { userId }, - columns: { - macUsed: true, - periodStart: true, - periodEnd: true, - monthlyBotMessagesPeriodStart: true, - }, - }) - const isLifetime = stored?.periodEnd == null + // Authoritative current counts assigned directly (not GREATEST) so + // removing contacts/team members/workspaces/channels frees quota, and + // the four billing markers read back in one round-trip. + const stored = await userQuotaService.reconcileUserSelfUsage(userId) + const isLifetime = stored.periodEnd == null await reconcileMac( userId, client, - stored?.macUsed ?? 0, - stored?.periodStart?.toISOString() ?? "", + stored.macUsed, + stored.periodStart?.toISOString() ?? "", isLifetime, ) await reconcileMonthlyBotMessages( userId, client, - stored?.monthlyBotMessagesPeriodStart ?? null, - stored?.periodStart ?? null, + stored.monthlyBotMessagesPeriodStart, + stored.periodStart, isLifetime, ) @@ -408,7 +272,7 @@ const reconcileMac = async ( ) } if (ledgerMac !== dbMacUsed) { - await persistMacUsed(userId, ledgerMac) + await userQuotaService.persistMacUsed(userId, ledgerMac) } return } @@ -433,24 +297,10 @@ const reconcileMac = async ( } if (action.persistMacUsed !== null) { - await persistMacUsed(userId, action.persistMacUsed) + await userQuotaService.persistMacUsed(userId, action.persistMacUsed) } } -/** Upsert `UserQuota.macUsed` to an absolute value. */ -const persistMacUsed = async (userId: string, value: number): Promise => { - await db - .insert(userQuotaModel) - .values({ userId, macUsed: value, syncedAt: new Date() }) - .onConflictDoUpdate({ - target: userQuotaModel.userId, - set: { - macUsed: value, - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }) -} - /** Live-counter hash field holding the running monthly-bot-messages count. */ const MONTHLY_BOT_MESSAGES_FIELD = "monthlyBotMessages" @@ -530,46 +380,23 @@ const reconcileMonthlyBotMessages = async ( return } + // Ordering is load-bearing: the DB counter is zeroed BEFORE the live Redis + // field, so `applyMonthlyBotMessagesReset` (which writes the DB row) is + // awaited first, and the live `hset` for the reset branch stays here, + // after it — fail-closed if a crash lands between the two. + await userQuotaService.applyMonthlyBotMessagesReset({ + userId, + periodStart, + reset: action.reset, + }) + if (action.reset) { - await db - .insert(userQuotaModel) - .values({ - userId, - monthlyBotMessagesUsed: 0, - monthlyBotMessagesPeriodStart: periodStart, - syncedAt: new Date(), - }) - .onConflictDoUpdate({ - target: userQuotaModel.userId, - set: { - monthlyBotMessagesUsed: 0, - monthlyBotMessagesPeriodStart: periodStart, - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }) await client.hset( liveKeyFor(USER_QUOTA_LABEL, userId), MONTHLY_BOT_MESSAGES_FIELD, "0", ) - return } - - // Unstamped row: adopt into the current period without touching the counter. - await db - .insert(userQuotaModel) - .values({ - userId, - monthlyBotMessagesPeriodStart: periodStart, - syncedAt: new Date(), - }) - .onConflictDoUpdate({ - target: userQuotaModel.userId, - set: { - monthlyBotMessagesPeriodStart: periodStart, - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }) } /** What `reconcileMac` should write, derived purely from the current state. */ diff --git a/apps/worker/src/sequence-scheduler/services/dispatch-processor.service.ts b/apps/worker/src/sequence-scheduler/services/dispatch-processor.service.ts index bf7d9ba96b..9ff78c5dc3 100644 --- a/apps/worker/src/sequence-scheduler/services/dispatch-processor.service.ts +++ b/apps/worker/src/sequence-scheduler/services/dispatch-processor.service.ts @@ -1,5 +1,4 @@ -import { and, db, eq } from "@chatbotx.io/database/client" -import { sequenceDispatchModel } from "@chatbotx.io/database/schema" +import { sequenceDispatchRepository } from "@chatbotx.io/database/repositories" import { logger } from "../../lib/logger" import type { DispatchWithRelations } from "./types" @@ -10,20 +9,11 @@ export class DispatchProcessorService { workspaceId: string, ) { try { - const dispatch = await db.query.sequenceDispatchModel.findFirst({ - where: { - id: dispatchId, - status: expectedStatus, - workspaceId, - }, - with: { - sequence: true, - contact: true, - enrollment: true, - }, + return await sequenceDispatchRepository.findWithRelations({ + id: dispatchId, + status: expectedStatus, + workspaceId, }) - - return dispatch ?? null } catch (error) { logger.error(error, "Error fetchDispatch query failed") return null @@ -48,23 +38,10 @@ export class DispatchProcessorService { } async lockDispatch(dispatch: DispatchWithRelations): Promise { - const updated = await db - .update(sequenceDispatchModel) - .set({ - status: "running", - lockedAt: new Date(), - lockOwner: process.env.HOSTNAME || "unknown", - updatedAt: new Date(), - }) - .where( - and( - eq(sequenceDispatchModel.id, dispatch.id), - eq(sequenceDispatchModel.workspaceId, dispatch.workspaceId), - eq(sequenceDispatchModel.status, "pending"), - ), - ) - .returning({ id: sequenceDispatchModel.id }) - - return updated.length > 0 + return await sequenceDispatchRepository.claim({ + id: dispatch.id, + workspaceId: dispatch.workspaceId, + lockOwner: process.env.HOSTNAME || "unknown", + }) } } diff --git a/apps/worker/src/sequence-scheduler/services/step-executor.service.ts b/apps/worker/src/sequence-scheduler/services/step-executor.service.ts index 50451aa7ba..67711b4048 100644 --- a/apps/worker/src/sequence-scheduler/services/step-executor.service.ts +++ b/apps/worker/src/sequence-scheduler/services/step-executor.service.ts @@ -1,12 +1,7 @@ -import { db } from "@chatbotx.io/database/client" +import type { SequenceStepWithFlow } from "@chatbotx.io/database/repositories" +import { sequenceDispatchRepository } from "@chatbotx.io/database/repositories" -type StepQueryResult = Awaited< - ReturnType< - typeof db.query.sequenceStepModel.findFirst<{ with: { flow: true } }> - > -> - -export type StepWithFlow = NonNullable +export type StepWithFlow = SequenceStepWithFlow export type StepWithConfiguredFlow = StepWithFlow & { flow: NonNullable } @@ -17,17 +12,10 @@ export type StepValidationResult = export class StepExecutorService { async fetchStep(stepId: string) { - const step = await db.query.sequenceStepModel.findFirst({ - where: { - id: stepId, - }, - with: { flow: true }, - }) - - return step + return await sequenceDispatchRepository.findStepWithFlow({ id: stepId }) } - validateStep(step: StepQueryResult): StepValidationResult { + validateStep(step: StepWithFlow | undefined): StepValidationResult { if (!step) { return { valid: false, reason: "step_not_found" } } diff --git a/apps/worker/src/sequence-scheduler/services/types.ts b/apps/worker/src/sequence-scheduler/services/types.ts index 8b7dba15a2..a43579f1e1 100644 --- a/apps/worker/src/sequence-scheduler/services/types.ts +++ b/apps/worker/src/sequence-scheduler/services/types.ts @@ -1,4 +1,4 @@ -import type { db } from "@chatbotx.io/database/client" +export type { DispatchWithRelations } from "@chatbotx.io/database/repositories" export interface ConsumerConfig { groupId: string @@ -8,16 +8,6 @@ export interface ConsumerConfig { sessionTimeout: number } -type DispatchQueryResult = Awaited< - ReturnType< - typeof db.query.sequenceDispatchModel.findFirst<{ - with: { sequence: true; contact: true; enrollment: true } - }> - > -> - -export type DispatchWithRelations = NonNullable - export type DispatchMessage = { dispatchId: string claimedAt: number diff --git a/apps/worker/src/sequence-scheduler/worker-producer.ts b/apps/worker/src/sequence-scheduler/worker-producer.ts index 6c3b521069..78bfe8d7cb 100644 --- a/apps/worker/src/sequence-scheduler/worker-producer.ts +++ b/apps/worker/src/sequence-scheduler/worker-producer.ts @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { sequenceDispatchRepository } from "@chatbotx.io/database/repositories" import { sequenceConnections } from "@chatbotx.io/redis" import { SchedulerClient } from "@chatbotx.io/scheduler" import { @@ -181,16 +181,10 @@ export class SchedulerWorker { dispatches: { dispatchId: string; bucket: number }[], ) { const dispatchIds = dispatches.map((dispatch) => dispatch.dispatchId) - const pendingDispatches = await db.query.sequenceDispatchModel.findMany({ - where: { - id: { in: dispatchIds }, - status: "pending", - }, - columns: { - id: true, - workspaceId: true, - }, - }) + const pendingDispatches = + await sequenceDispatchRepository.listPendingWorkspaceIds({ + ids: dispatchIds, + }) const workspaceByDispatchId = new Map( pendingDispatches.map((dispatch) => [dispatch.id, dispatch.workspaceId]), ) diff --git a/apps/worker/src/sequence-scheduler/worker.ts b/apps/worker/src/sequence-scheduler/worker.ts index 08be629da4..848e3082ed 100644 --- a/apps/worker/src/sequence-scheduler/worker.ts +++ b/apps/worker/src/sequence-scheduler/worker.ts @@ -1,4 +1,4 @@ -import { db, sql } from "@chatbotx.io/database/client" +import { sequenceDispatchRepository } from "@chatbotx.io/database/repositories" import { sequenceConnections } from "@chatbotx.io/redis" import { SchedulerClient } from "@chatbotx.io/scheduler" import { ensureBootstrapped } from "../lib/bootstrap" @@ -93,23 +93,13 @@ export class ReconcileJob { let offset = 0 while (hasMore) { - const dispatches = await db.query.sequenceDispatchModel.findMany({ - // status=pending intentionally prunes this scan to SequenceDispatch_pending. - where: { - status: "pending", - runAtMs: { lte: String(windowEnd.getTime()) }, - }, - columns: { - id: true, - bucket: true, - runAtMs: true, - workspaceId: true, - contactId: true, - }, - orderBy: (d, { asc }) => [asc(d.runAtMs)], - offset, - limit: BATCH_SIZE, - }) + // status=pending intentionally prunes this scan to SequenceDispatch_pending. + const dispatches = + await sequenceDispatchRepository.listPendingForReconcile({ + maxRunAtMs: String(windowEnd.getTime()), + offset, + limit: BATCH_SIZE, + }) if (dispatches.length === 0) { hasMore = false @@ -179,13 +169,9 @@ export class ReconcileJob { continue } - const validDispatches = await db.query.sequenceDispatchModel.findMany({ - where: { - id: { in: allIds }, - status: "pending", - }, - columns: { id: true }, - }) + const validDispatches = await sequenceDispatchRepository.listPendingIds( + { ids: allIds }, + ) const validIds = new Set(validDispatches.map((d) => d.id)) const orphanIds = allIds.filter((id) => !validIds.has(id)) @@ -239,21 +225,10 @@ export class ReconcileJob { let deletedCount = 0 while (true) { - const result = await db.execute<{ id: string }>(sql` - WITH rows AS ( - SELECT "id", "workspaceId" - FROM "SequenceDispatch" - WHERE "status" IN ('completed', 'failed', 'canceled') - AND "updatedAt" < NOW() - (${retentionTtlDays} * INTERVAL '1 day') - LIMIT ${batchSize} - ) - DELETE FROM "SequenceDispatch" sd - USING rows - WHERE sd."id" = rows."id" - AND sd."workspaceId" = rows."workspaceId" - RETURNING sd."id" - `) - const rowCount = result.rows.length + const rowCount = await sequenceDispatchRepository.deleteTerminalBatch({ + retentionTtlDays, + batchSize, + }) deletedCount += rowCount if (rowCount < batchSize) { diff --git a/apps/worker/src/trigger/services/action-executor.ts b/apps/worker/src/trigger/services/action-executor.ts index 03fd15ca06..47d6160840 100644 --- a/apps/worker/src/trigger/services/action-executor.ts +++ b/apps/worker/src/trigger/services/action-executor.ts @@ -3,16 +3,14 @@ import { botFieldService, contactCustomFieldService, conversationService, + flowService, metaConversionsService, + tagService, tagSyncService, } from "@chatbotx.io/business" -import { and, db, eq, inArray } from "@chatbotx.io/database/client" import { triggerActions } from "@chatbotx.io/database/partials" import type { ContactInboxWorkspaceRow } from "@chatbotx.io/database/repositories" -import { - contactsToTagsModel, - metaCapiEventChannelSchema, -} from "@chatbotx.io/database/schema" +import { metaCapiEventChannelSchema } from "@chatbotx.io/database/schema" import { webhookChannelOrigin } from "@chatbotx.io/events/context" import { errorStateDefaultFn, @@ -73,14 +71,9 @@ export class ActionExecutor { const { action, contactId, triggerId, workspaceId } = context const actionType = action.type - const conversation = await db.query.conversationModel.findFirst({ - where: { - contactId, - workspaceId, - }, - orderBy: { - createdAt: "desc", - }, + const conversation = await conversationService.findLatestCreatedByContact({ + workspaceId, + contactId, }) if (!conversation) { @@ -107,38 +100,23 @@ export class ActionExecutor { switch (actionType) { case triggerActions.enum.addTag: { const tagIds = action.tagIds as string[] - const existingTags = await db.query.tagModel.findMany({ - where: { - id: { in: tagIds }, - workspaceId, - deletedAt: { isNull: true as const }, - }, + const newlyLinked = await tagService.attachExistingToContactForTrigger({ + workspaceId, + contactId: conversation.contactId, + tagIds, }) - if (existingTags.length > 0) { - const newlyLinked = await db - .insert(contactsToTagsModel) - .values( - existingTags.map((t) => ({ - contactId: conversation.contactId, - tagId: t.id, - })), - ) - .onConflictDoNothing() - .returning({ tagId: contactsToTagsModel.tagId }) - - for (const link of newlyLinked) { - await tagSyncService.enqueueAttach({ - workspaceId, - contactId: conversation.contactId, - tagId: link.tagId, - }) - await adsConversionService.enqueueTagAppliedEvaluations({ - workspaceId, - contactId: conversation.contactId, - tagId: link.tagId, - }) - } + for (const link of newlyLinked) { + await tagSyncService.enqueueAttach({ + workspaceId, + contactId: conversation.contactId, + tagId: link.tagId, + }) + await adsConversionService.enqueueTagAppliedEvaluations({ + workspaceId, + contactId: conversation.contactId, + tagId: link.tagId, + }) } break } @@ -146,14 +124,11 @@ export class ActionExecutor { case triggerActions.enum.removeTag: { const tagIds = action.tagIds as string[] if (tagIds.length > 0) { - await db - .delete(contactsToTagsModel) - .where( - and( - eq(contactsToTagsModel.contactId, conversation.contactId), - inArray(contactsToTagsModel.tagId, tagIds), - ), - ) + await tagService.detachFromContactForTrigger({ + workspaceId, + contactId: conversation.contactId, + tagIds, + }) // Channel cleanup (unassign + delete ContactToTagChannel) runs in the queue. for (const tagId of tagIds) { await tagSyncService.enqueueDetach({ @@ -247,12 +222,9 @@ export class ActionExecutor { } const flowId = action.flowId as string - const flow = await db.query.flowModel.findFirst({ - where: { - id: flowId, - workspaceId, - active: true, - }, + const flow = await flowService.findActiveById({ + workspaceId, + id: flowId, }) if (!flow?.currentVersionId) { diff --git a/apps/worker/src/trigger/services/condition-evaluator.ts b/apps/worker/src/trigger/services/condition-evaluator.ts index 860d100c36..9359127f51 100644 --- a/apps/worker/src/trigger/services/condition-evaluator.ts +++ b/apps/worker/src/trigger/services/condition-evaluator.ts @@ -1,4 +1,7 @@ -import { db } from "@chatbotx.io/database/client" +import { + contactCustomFieldService, + customFieldService, +} from "@chatbotx.io/business" import { type OperatorType, triggerEventTypes, @@ -94,20 +97,14 @@ export class ConditionEvaluator { if (customFieldId === actualCustomFieldId) { actualValue = metadata.newValue } else { - const contactCustomField = - await db.query.contactCustomFieldModel.findFirst({ - where: { - contactId, - customFieldId, - }, - columns: { value: true }, - }) - actualValue = contactCustomField?.value + actualValue = await contactCustomFieldService.findValue({ + contactId, + customFieldId, + }) } - const customField = await db.query.customFieldModel.findFirst({ + const customField = await customFieldService.findBy({ where: { id: customFieldId }, - columns: { type: true }, }) if (!operator) { @@ -391,22 +388,15 @@ export class ConditionEvaluator { return false } - const contactCustomField = await db.query.contactCustomFieldModel.findFirst( - { - where: { - contactId, - customFieldId, - }, - columns: { value: true }, - }, - ) + const customFieldValue = await contactCustomFieldService.findValue({ + contactId, + customFieldId, + }) - if (!contactCustomField?.value) { + if (!customFieldValue) { return false } - const customFieldValue = contactCustomField.value as string - const config = triggerConfig as { triggerType?: string timeValue?: number diff --git a/apps/worker/src/trigger/services/datetime-trigger-evaluator.ts b/apps/worker/src/trigger/services/datetime-trigger-evaluator.ts index acb9ee15cf..a7213f9dee 100644 --- a/apps/worker/src/trigger/services/datetime-trigger-evaluator.ts +++ b/apps/worker/src/trigger/services/datetime-trigger-evaluator.ts @@ -1,11 +1,9 @@ -import { db, sql } from "@chatbotx.io/database/client" +import { triggerService } from "@chatbotx.io/business" import { triggerEventTypes } from "@chatbotx.io/database/partials" import { listContactCustomFieldsForDateTimeSweep, listContactCustomFieldsForDateTimeSweepContacts, } from "@chatbotx.io/database/repositories" -import { triggerExecutionModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" import { getRedisConnection } from "@chatbotx.io/worker-config" import { logger } from "../../lib/logger" import { @@ -46,18 +44,11 @@ async function fetchTriggerChunk( cursor: string | undefined, chunkSize: number, ): Promise<{ triggerMap: TriggerMap; nextCursor: string | undefined }> { - const triggers = await db.query.triggerModel.findMany({ - where: { - active: true, - ...(cursor ? { id: { gt: cursor } } : {}), - }, - with: { - conditions: true, - workspace: true, - }, - limit: chunkSize, - orderBy: { id: "asc" }, - }) + const { triggers, nextCursor } = + await triggerService.listActiveWithConditionsPage({ + cursor, + limit: chunkSize, + }) const filteredTriggers = triggers.filter((t) => t.conditions.some( @@ -109,9 +100,6 @@ async function fetchTriggerChunk( } } - const nextCursor = - triggers.length === chunkSize ? triggers.at(-1)?.id : undefined - return { triggerMap, nextCursor } } @@ -119,15 +107,9 @@ async function getExecutedTriggers( triggerIds: string[], contactIds: string[], ): Promise> { - const executions = await db.query.triggerExecutionModel.findMany({ - where: { - triggerId: { in: triggerIds }, - contactId: { in: contactIds }, - }, - columns: { - triggerId: true, - contactId: true, - }, + const executions = await triggerService.listExecutedPairs({ + triggerIds, + contactIds, }) return new Set(executions.map((e) => `${e.triggerId}:${e.contactId}`)) @@ -191,17 +173,11 @@ async function markTriggerExecuted( triggerInfo: TriggerSweepInfo, contactId: string, ): Promise { - await db - .insert(triggerExecutionModel) - .values({ - id: createId(), - triggerId: triggerInfo.triggerId, - contactId, - workspaceId: triggerInfo.workspaceId, - createdAt: new Date(), - executedAt: new Date(), - }) - .onConflictDoNothing() + await triggerService.recordExecution({ + triggerId: triggerInfo.triggerId, + contactId, + workspaceId: triggerInfo.workspaceId, + }) const cacheKey = `trigger:executed:${triggerInfo.triggerId}:${contactId}` await redis.setex(cacheKey, 86_400 * 90, "1") @@ -452,9 +428,5 @@ export async function cleanupOldExecutions(): Promise { const ninetyDaysAgo = new Date() ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90) - const result = await db.execute( - sql`DELETE FROM "TriggerExecution" WHERE "executedAt" < ${ninetyDaysAgo}`, - ) - - return Number(result.rowCount ?? 0) + return await triggerService.purgeExecutionsOlderThan(ninetyDaysAgo) } diff --git a/apps/worker/src/trigger/services/trigger-executor.service.ts b/apps/worker/src/trigger/services/trigger-executor.service.ts index 85eb16fff8..ac45443c83 100644 --- a/apps/worker/src/trigger/services/trigger-executor.service.ts +++ b/apps/worker/src/trigger/services/trigger-executor.service.ts @@ -1,10 +1,5 @@ -import { db, sql } from "@chatbotx.io/database/client" -import { - triggerContactHistoryModel, - triggerStatsModel, -} from "@chatbotx.io/database/schema" +import { triggerService } from "@chatbotx.io/business" import { setTriggerExecutionContext } from "@chatbotx.io/events" -import { createId } from "@chatbotx.io/utils" import { logger } from "../../lib/logger" import type { TriggerExecutionInput, TriggerWithConditions } from "../types" import { ActionExecutor } from "./action-executor" @@ -45,12 +40,10 @@ export class TriggerExecutorService { } } - await db.insert(triggerContactHistoryModel).values({ - id: createId(), + await triggerService.recordContactHistory({ triggerId, contactId, workspaceId, - firstEnteredAt: new Date(), }) await this.updateStats(triggerId, workspaceId, true) @@ -78,30 +71,11 @@ export class TriggerExecutorService { const today = new Date() today.setHours(0, 0, 0, 0) - await db - .insert(triggerStatsModel) - .values({ - id: createId(), - triggerId, - workspaceId, - date: today, - totalContacts: 1, - totalExecutions: 1, - successCount: success ? 1 : 0, - failureCount: success ? 0 : 1, - }) - .onConflictDoUpdate({ - target: [triggerStatsModel.triggerId, triggerStatsModel.date], - set: { - totalContacts: sql`${triggerStatsModel.totalContacts} + 1`, - totalExecutions: sql`${triggerStatsModel.totalExecutions} + 1`, - successCount: success - ? sql`${triggerStatsModel.successCount} + 1` - : triggerStatsModel.successCount, - failureCount: success - ? triggerStatsModel.failureCount - : sql`${triggerStatsModel.failureCount} + 1`, - }, - }) + await triggerService.incrementStats({ + triggerId, + workspaceId, + date: today, + success, + }) } } diff --git a/apps/worker/src/trigger/services/trigger-matcher.service.ts b/apps/worker/src/trigger/services/trigger-matcher.service.ts index ba7fc9fa85..eaad6a60db 100644 --- a/apps/worker/src/trigger/services/trigger-matcher.service.ts +++ b/apps/worker/src/trigger/services/trigger-matcher.service.ts @@ -1,5 +1,4 @@ -import { workspaceService } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" +import { triggerService, workspaceService } from "@chatbotx.io/business" import type { TriggerEventType } from "@chatbotx.io/database/partials" import type { WorkspaceModel } from "@chatbotx.io/database/types" import { matchableConditionTypesFor } from "@chatbotx.io/events" @@ -32,14 +31,8 @@ export class TriggerMatcherService { const sourceId = metadata.sourceId as string | undefined - const triggers = await db.query.triggerModel.findMany({ - where: { - workspaceId, - active: true, - }, - with: { - conditions: true, - }, + const triggers = await triggerService.listActiveWithConditions({ + workspaceId, }) // Filter triggers that have matching conditions diff --git a/apps/worker/src/webhook/services/webhook-matcher.service.ts b/apps/worker/src/webhook/services/webhook-matcher.service.ts index 87bb2b0b0b..2ac6198c67 100644 --- a/apps/worker/src/webhook/services/webhook-matcher.service.ts +++ b/apps/worker/src/webhook/services/webhook-matcher.service.ts @@ -1,5 +1,4 @@ -import { workspaceService } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" +import { webhookService, workspaceService } from "@chatbotx.io/business" import type { TriggerEventType } from "@chatbotx.io/database/partials" import type { WorkspaceModel } from "@chatbotx.io/database/types" import { @@ -54,14 +53,8 @@ export class WebhookMatcherService { const sourceId = metadata.sourceId as string | undefined - const webhooks = await db.query.webhookModel.findMany({ - where: { - workspaceId, - active: true, - }, - with: { - conditions: true, - }, + const webhooks = await webhookService.listActiveWithConditions({ + workspaceId, }) // Filter webhooks that have matching conditions diff --git a/apps/worker/src/webhook/services/webhook-payload.builder.ts b/apps/worker/src/webhook/services/webhook-payload.builder.ts index 84c34b8d01..c130f7639c 100644 --- a/apps/worker/src/webhook/services/webhook-payload.builder.ts +++ b/apps/worker/src/webhook/services/webhook-payload.builder.ts @@ -1,8 +1,8 @@ import { contactCustomFieldService, contactService, + tagService, } from "@chatbotx.io/business" -import { db } from "@chatbotx.io/database/client" import { triggerEventTypes } from "@chatbotx.io/database/partials" import type { MatchableEventType } from "@chatbotx.io/events" import type { MatchableWebhookEventData, WebhookPayload } from "../types" @@ -53,18 +53,14 @@ async function buildTagPayload( // Scoped by workspace because the tag id comes from event metadata and tag ids // are globally unique: an id-only lookup would resolve another tenant's tag // and put its name in this workspace's outbound payload. - const tag = await db.query.tagModel.findFirst({ - where: { - id: data.tagId as string, - workspaceId, - deletedAt: { isNull: true as const }, - }, - columns: { name: true }, + const tagName = await tagService.findNameByIdForWorkspace({ + workspaceId, + id: data.tagId as string, }) return { ...basePayload, - tag: tag?.name || "", + tag: tagName || "", } } diff --git a/packages/business/__tests__/broadcast-service-prepare.test.ts b/packages/business/__tests__/broadcast-service-prepare.test.ts new file mode 100644 index 0000000000..b3ca1f1a44 --- /dev/null +++ b/packages/business/__tests__/broadcast-service-prepare.test.ts @@ -0,0 +1,363 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// broadcastService — the prepare/process-broadcast-contacts surface moved +// from the schedule handlers: listDueScheduled, listSendingAwaitingHandoff, +// findScheduledForPrepare, resolveTemplateIntegrationMessengerId, +// insertRecipients, promoteAfterPrepare (the resumeCount CAS), listSendableById, +// listPendingRecipients, markContactFailed. Mirrors the mock scaffolding in +// broadcast-service-lifecycle.test.ts. +// --------------------------------------------------------------------------- + +const findManyBroadcast = vi.fn() +const findFirstBroadcast = vi.fn() +const findFirstTemplate = vi.fn() +const findManyContactsOnBroadcasts = vi.fn() +const updateReturning = vi.fn() +const updateWhere = vi.fn() +const insertOnConflictDoNothing = vi.fn() + +vi.mock("@chatbotx.io/analytics", () => ({ + broadcastAnalyticsService: { getContacts: vi.fn() }, + sequenceAnalyticsService: { getContacts: vi.fn() }, +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + broadcastModel: { + findMany: (...args: unknown[]) => findManyBroadcast(...args), + findFirst: (...args: unknown[]) => findFirstBroadcast(...args), + }, + messengerMessageTemplateModel: { + findFirst: (...args: unknown[]) => findFirstTemplate(...args), + }, + contactsOnBroadcastsModel: { + findMany: (...args: unknown[]) => findManyContactsOnBroadcasts(...args), + }, + }, + update: () => ({ + set: (values: Record) => ({ + where: (condition: unknown) => { + updateWhere({ values, condition }) + return { returning: () => updateReturning({ values, condition }) } + }, + }), + }), + insert: () => ({ + values: (values: unknown) => ({ + onConflictDoNothing: () => insertOnConflictDoNothing({ values }), + }), + }), + select: () => ({ from: () => ({ where: () => [] }) }), + }, + and: (...args: unknown[]) => ({ __and: args }), + asc: vi.fn(), + count: vi.fn(), + desc: vi.fn(), + eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), + gt: vi.fn(), + inArray: vi.fn(), + isNotNull: (a: unknown) => ({ __isNotNull: a }), + isNull: (a: unknown) => ({ __isNull: a }), + or: (...args: unknown[]) => ({ __or: args }), + sql: Object.assign( + (_strings: TemplateStringsArray, ..._values: unknown[]) => ({ + mapWith: (_fn: unknown) => ({ __sql: true }), + }), + { raw: vi.fn() }, + ), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: { + id: "broadcast.id", + workspaceId: "broadcast.workspaceId", + status: "broadcast.status", + handoffCompletedAt: "broadcast.handoffCompletedAt", + resumeCount: "broadcast.resumeCount", + deletedAt: "broadcast.deletedAt", + }, + contactsOnBroadcastsModel: { + broadcastId: "cob.broadcastId", + contactId: "cob.contactId", + deliveredAt: "cob.deliveredAt", + failedAt: "cob.failedAt", + errorContent: "cob.errorContent", + }, + contactInboxModel: {}, + contactModel: {}, + conversationModel: {}, + integrationMessengerModel: {}, + integrationWhatsappModel: {}, + messengerMessageTemplateModel: {}, + whatsappMessageTemplateModel: {}, +})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactInboxContactFilterSQL: vi.fn(), + contactInboxInteractedWithin24hSQL: vi.fn(), + pruneEmailPhoneFilterConditions: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + chunkById: vi.fn(), + likeContains: (value: string) => `%${value}%`, +})) + +vi.mock("../src/inbox/service", () => ({ inboxService: {} })) + +const { broadcastService } = await import("../src/broadcast/service") + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("listDueScheduled", () => { + test("returns id-only rows for scheduled broadcasts due at or before dueAt", async () => { + findManyBroadcast.mockResolvedValue([{ id: "b-1" }]) + + const result = await broadcastService.listDueScheduled({ + dueAt: new Date("2026-01-01T00:00:00Z"), + }) + + expect(result).toEqual([{ id: "b-1" }]) + expect(findManyBroadcast).toHaveBeenCalledWith( + expect.objectContaining({ columns: { id: true } }), + ) + }) +}) + +describe("listSendingAwaitingHandoff", () => { + test("returns sending broadcasts whose handoff has not completed", async () => { + findManyBroadcast.mockResolvedValue([{ id: "b-1" }]) + + const result = await broadcastService.listSendingAwaitingHandoff() + + expect(result).toEqual([{ id: "b-1" }]) + expect(findManyBroadcast).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "sending", + handoffCompletedAt: { isNull: true }, + deletedAt: { isNull: true }, + }), + columns: { id: true }, + }), + ) + }) +}) + +describe("findScheduledForPrepare", () => { + test("looks up the scheduled, non-deleted broadcast by id", async () => { + const row = { id: "b-1", workspaceId: "ws-1", resumeCount: 0 } + findFirstBroadcast.mockResolvedValue(row) + + const result = await broadcastService.findScheduledForPrepare({ + broadcastId: "b-1", + }) + + expect(result).toEqual(row) + expect(findFirstBroadcast).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "b-1", + status: "scheduled", + deletedAt: { isNull: true }, + }), + with: { targets: { columns: { inboxId: true } } }, + }), + ) + }) +}) + +describe("resolveTemplateIntegrationMessengerId", () => { + test("resolves the page id scoping through the nested integrationMessenger relation", async () => { + findFirstTemplate.mockResolvedValue({ integrationMessengerId: "im-1" }) + + const result = await broadcastService.resolveTemplateIntegrationMessengerId( + { workspaceId: "ws-1", templateId: "t-1" }, + ) + + expect(result).toBe("im-1") + expect(findFirstTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "t-1", + integrationMessenger: { workspaceId: "ws-1" }, + }), + }), + ) + }) + + test("returns null when no template matches", async () => { + findFirstTemplate.mockResolvedValue(undefined) + + const result = await broadcastService.resolveTemplateIntegrationMessengerId( + { workspaceId: "ws-1", templateId: "missing" }, + ) + + expect(result).toBeNull() + }) +}) + +describe("insertRecipients", () => { + test("no-ops without an insert when recipients is empty", async () => { + await broadcastService.insertRecipients({ recipients: [] }) + expect(insertOnConflictDoNothing).not.toHaveBeenCalled() + }) + + test("bulk-inserts recipient rows with onConflictDoNothing", async () => { + const recipients = [ + { + broadcastId: "b-1", + contactId: "c-1", + contactInboxId: "ci-1", + conversationId: "conv-1", + }, + ] + + await broadcastService.insertRecipients({ recipients }) + + expect(insertOnConflictDoNothing).toHaveBeenCalledTimes(1) + expect(insertOnConflictDoNothing).toHaveBeenCalledWith({ + values: recipients, + }) + }) +}) + +describe("promoteAfterPrepare", () => { + test("returns true when the CAS update affects a row", async () => { + updateReturning.mockReturnValue([{ id: "b-1" }]) + + const result = await broadcastService.promoteAfterPrepare({ + broadcastId: "b-1", + status: "sending", + contactCount: 5, + promotionEpoch: 3, + }) + + expect(result).toBe(true) + }) + + test("returns false when the epoch CAS loses the race (resumeCount moved on)", async () => { + updateReturning.mockReturnValue([]) + + const result = await broadcastService.promoteAfterPrepare({ + broadcastId: "b-1", + status: "sending", + contactCount: 5, + promotionEpoch: 3, + }) + + expect(result).toBe(false) + }) + + test("builds the WHERE with id, scheduled status, not-deleted, and the resumeCount epoch guard", async () => { + updateReturning.mockReturnValue([{ id: "b-1" }]) + + await broadcastService.promoteAfterPrepare({ + broadcastId: "b-1", + status: "sending", + contactCount: 5, + promotionEpoch: 3, + }) + + expect(updateWhere).toHaveBeenCalledWith( + expect.objectContaining({ + condition: { + __and: [ + { __eq: ["broadcast.id", "b-1"] }, + { __eq: ["broadcast.status", "scheduled"] }, + { __isNull: "broadcast.deletedAt" }, + { __eq: ["broadcast.resumeCount", 3] }, + ], + }, + }), + ) + }) +}) + +describe("listSendableById", () => { + test("returns the array of sending broadcasts matching the id", async () => { + findManyBroadcast.mockResolvedValue([{ id: "b-1", status: "sending" }]) + + const result = await broadcastService.listSendableById({ + broadcastId: "b-1", + }) + + expect(result).toEqual([{ id: "b-1", status: "sending" }]) + // Scoping relocated from process-broadcast-contacts.test.ts: only a + // `sending`, non-deleted broadcast with this id is sendable. + expect(findManyBroadcast).toHaveBeenCalledWith({ + where: { + id: "b-1", + status: "sending", + deletedAt: { isNull: true }, + }, + with: { + targets: { + columns: { + inboxId: true, + flowId: true, + templateId: true, + templateData: true, + }, + }, + }, + }) + }) +}) + +describe("listPendingRecipients", () => { + test("passes the limit through and includes conversation/contactInbox relations", async () => { + findManyContactsOnBroadcasts.mockResolvedValue([]) + + await broadcastService.listPendingRecipients({ + broadcastId: "b-1", + limit: 500, + }) + + // Scoping relocated from process-broadcast-contacts.test.ts: only unsent, + // non-terminal-failed recipients are fetched. + expect(findManyContactsOnBroadcasts).toHaveBeenCalledWith({ + where: { + broadcastId: "b-1", + sent: false, + failedAt: { isNull: true }, + }, + with: { conversation: true, contactInbox: true }, + limit: 500, + }) + }) +}) + +describe("markContactFailed", () => { + test("updates failedAt/errorContent scoped to (broadcastId, contactId)", async () => { + await broadcastService.markContactFailed({ + broadcastId: "b-1", + contactId: "c-1", + reason: "missing conversation for flow send", + }) + + expect(updateWhere).toHaveBeenCalledWith( + expect.objectContaining({ + values: expect.objectContaining({ + errorContent: "missing conversation for flow send", + }), + }), + ) + // Scoping must be exactly (broadcastId, contactId) — dropping either + // condition from the conjunction would let this update touch another + // broadcast's or another contact's row. + const { condition } = updateWhere.mock.calls[0][0] as { + condition: { __and: unknown[] } + } + expect(condition).toEqual({ + __and: [ + { __eq: ["cob.broadcastId", "b-1"] }, + { __eq: ["cob.contactId", "c-1"] }, + ], + }) + }) +}) diff --git a/packages/business/__tests__/contact-insert-imported-batch.test.ts b/packages/business/__tests__/contact-insert-imported-batch.test.ts new file mode 100644 index 0000000000..ead2b483ab --- /dev/null +++ b/packages/business/__tests__/contact-insert-imported-batch.test.ts @@ -0,0 +1,320 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// insertImportedContactBatch — the transaction body moved verbatim from +// imports/handler/contacts/handler.ts's insertContactBatch. Mocks `db` at the +// module boundary (a fake `tx` object passed through db.transaction) plus the +// cross-domain services the transaction calls into. +// --------------------------------------------------------------------------- + +const { + insertContact, + insertContactInboxValues, + insertContactInboxReturning, + deleteContactWhere, + insertConversationValues, + insertTagsOnConflictDoNothing, + cancelByInboxSource, + insertNormalizedValuesForNewContacts, + tx, + CONTACT_MODEL, + CONTACT_INBOX_MODEL, + CONVERSATION_MODEL, + CONTACTS_TO_TAGS_MODEL, +} = vi.hoisted(() => { + const insertContact = vi.fn() + const insertContactInboxValues = vi.fn() + const insertContactInboxReturning = vi.fn() + const deleteContactWhere = vi.fn() + const insertConversationValues = vi.fn() + const insertTagsOnConflictDoNothing = vi.fn() + const cancelByInboxSource = vi.fn() + const insertNormalizedValuesForNewContacts = vi.fn() + + const CONTACT_MODEL = { __name: "contactModel" } + const CONTACT_INBOX_MODEL = { + __name: "contactInboxModel", + contactId: "contactInbox.contactId", + } + const CONVERSATION_MODEL = { __name: "conversationModel" } + const CONTACTS_TO_TAGS_MODEL = { __name: "contactsToTagsModel" } + + const tx = { + insert: vi.fn((table: unknown) => { + if (table === CONTACT_MODEL) { + return { values: (v: unknown) => insertContact(v) } + } + if (table === CONTACT_INBOX_MODEL) { + return { + values: (v: unknown) => { + insertContactInboxValues(v) + return { + onConflictDoNothing: () => ({ + returning: () => insertContactInboxReturning(), + }), + } + }, + } + } + if (table === CONVERSATION_MODEL) { + return { values: (v: unknown) => insertConversationValues(v) } + } + if (table === CONTACTS_TO_TAGS_MODEL) { + return { + values: (v: unknown) => ({ + onConflictDoNothing: () => insertTagsOnConflictDoNothing(v), + }), + } + } + return { values: vi.fn() } + }), + delete: vi.fn(() => ({ + where: (...args: unknown[]) => deleteContactWhere(...args), + })), + } + + return { + insertContact, + insertContactInboxValues, + insertContactInboxReturning, + deleteContactWhere, + insertConversationValues, + insertTagsOnConflictDoNothing, + cancelByInboxSource, + insertNormalizedValuesForNewContacts, + tx, + CONTACT_MODEL, + CONTACT_INBOX_MODEL, + CONVERSATION_MODEL, + CONTACTS_TO_TAGS_MODEL, + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + transaction: (fn: (tx: unknown) => unknown) => fn(tx), + }, + inArray: (a: unknown, b: unknown) => ({ inArray: [a, b] }), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + contactSources: { enum: { imported: "imported" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactModel: CONTACT_MODEL, + contactInboxModel: CONTACT_INBOX_MODEL, + contactsToTagsModel: CONTACTS_TO_TAGS_MODEL, + conversationModel: CONVERSATION_MODEL, +})) + +vi.mock("@chatbotx.io/utils", () => ({ createId: vi.fn(() => "generated-id") })) + +vi.mock("../src/contact-custom-field/service", () => ({ + contactCustomFieldService: { + insertNormalizedValuesForNewContacts: (...a: unknown[]) => + insertNormalizedValuesForNewContacts(...a), + }, +})) + +vi.mock("../src/message-cleanup/service", () => ({ + messageCleanupService: { + cancelByInboxSource: (...a: unknown[]) => cancelByInboxSource(...a), + }, +})) + +vi.mock("../src/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() } })) + +// contactService is imported by insert-imported-batch.ts to attach the method +// onto it — stub a minimal object so Object.assign has a target. +vi.mock("../src/contact/service", () => ({ + contactService: {}, + ContactService: class {}, +})) + +const { insertImportedContactBatch } = await import( + "../src/contact/insert-imported-batch" +) + +beforeEach(() => { + vi.clearAllMocks() + insertContactInboxReturning.mockResolvedValue([]) +}) + +const baseInput = { + workspaceId: "ws-1", + inbox: { id: "inbox-1", channel: "whatsapp" as const }, +} + +describe("insertImportedContactBatch", () => { + test("returns { inserted: 0, orphanCount: 0 } without a transaction when accepted is empty", async () => { + const result = await insertImportedContactBatch({ + ...baseInput, + accepted: [], + }) + + expect(result).toEqual({ inserted: 0, orphanCount: 0 }) + expect(tx.insert).not.toHaveBeenCalled() + }) + + // Relocated from apps/worker/__tests__/import-contacts-handler.test.ts's + // "BSUID-only row creates a BSUID-keyed ContactInbox" case: the worker test + // can no longer see the row values now that it mocks this method. + test("maps each accepted row onto a Contact row and a ContactInbox row", async () => { + insertContactInboxReturning.mockResolvedValue([{ contactId: "c-1" }]) + + await insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { + externalId: "user.9373928427292738", + sourceUserId: "user.9373928427292738", + phoneNumber: "+15551234567", + email: "a@example.com", + firstName: "Ada", + lastName: "Lovelace", + customFields: [], + }, + }, + ], + }) + + expect(insertContact).toHaveBeenCalledWith([ + { + id: "c-1", + workspaceId: baseInput.workspaceId, + phoneNumber: "+15551234567", + email: "a@example.com", + firstName: "Ada", + lastName: "Lovelace", + }, + ]) + // A BSUID-only row is keyed by its sourceUserId: sourceId equals it. + expect(insertContactInboxValues).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "ci-1", + contactId: "c-1", + originalContactId: "c-1", + inboxId: baseInput.inbox.id, + channel: baseInput.inbox.channel, + sourceId: "user.9373928427292738", + sourceUserId: "user.9373928427292738", + }), + ]) + }) + + test("throws the externalId invariant when a row lacks externalId", async () => { + await expect( + insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: null, customFields: [] }, + }, + ], + }), + ).rejects.toThrow("Invariant: externalId must be set before insert") + }) + + test("orphan prune fires when a contact-inbox insert conflicts", async () => { + // Two accepted rows; only c-1's inbox insert survives the conflict. + insertContactInboxReturning.mockResolvedValue([{ contactId: "c-1" }]) + + const result = await insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: "ext-1", customFields: [] }, + }, + { + contactId: "c-2", + contactInboxId: "ci-2", + row: { externalId: "ext-2", customFields: [] }, + }, + ], + }) + + expect(result.orphanCount).toBe(1) + expect(result.inserted).toBe(1) + expect(deleteContactWhere).toHaveBeenCalled() + }) + + test("cancelByInboxSource receives the shared tx handle", async () => { + insertContactInboxReturning.mockResolvedValue([{ contactId: "c-1" }]) + + await insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: "ext-1", customFields: [] }, + }, + ], + }) + + expect(cancelByInboxSource).toHaveBeenCalledWith( + expect.objectContaining({ inboxId: "inbox-1", tx }), + ) + }) + + test("tag insert only runs when tagId is present", async () => { + insertContactInboxReturning.mockResolvedValue([{ contactId: "c-1" }]) + + await insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: "ext-1", customFields: [] }, + }, + ], + }) + expect(insertTagsOnConflictDoNothing).not.toHaveBeenCalled() + + vi.clearAllMocks() + insertContactInboxReturning.mockResolvedValue([{ contactId: "c-1" }]) + + await insertImportedContactBatch({ + ...baseInput, + tagId: "tag-1", + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: "ext-1", customFields: [] }, + }, + ], + }) + expect(insertTagsOnConflictDoNothing).toHaveBeenCalledWith([ + { contactId: "c-1", tagId: "tag-1" }, + ]) + }) + + test("returns { inserted: 0 } without conversation/custom-field inserts when every row is an orphan", async () => { + insertContactInboxReturning.mockResolvedValue([]) // nothing survives + + const result = await insertImportedContactBatch({ + ...baseInput, + accepted: [ + { + contactId: "c-1", + contactInboxId: "ci-1", + row: { externalId: "ext-1", customFields: [] }, + }, + ], + }) + + expect(result).toEqual({ inserted: 0, orphanCount: 1 }) + expect(insertConversationValues).not.toHaveBeenCalled() + expect(insertNormalizedValuesForNewContacts).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/tag-service-sync-helpers.test.ts b/packages/business/__tests__/tag-service-sync-helpers.test.ts new file mode 100644 index 0000000000..6c2eb04db4 --- /dev/null +++ b/packages/business/__tests__/tag-service-sync-helpers.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// tagService — the sync/trigger helper surface: findById, findManyByIds, +// findNameByIdForWorkspace, hardDeleteSoftDeleted, +// attachExistingToContactForTrigger, detachFromContactForTrigger. Mirrors the +// mock scaffolding in tag-service-bulk-attach.test.ts. +// --------------------------------------------------------------------------- + +const findFirstTag = vi.fn() +const findManyTag = vi.fn() +const insertValues = vi.fn() +const insertReturning = vi.fn() +const deleteWhere = vi.fn() +const invalidateCacheByTags = vi.fn() + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + tagModel: { + findFirst: (...args: unknown[]) => findFirstTag(...args), + findMany: (...args: unknown[]) => findManyTag(...args), + }, + }, + insert: () => ({ + values: (values: unknown) => { + insertValues(values) + return { + onConflictDoNothing: () => ({ + returning: (...args: unknown[]) => insertReturning(...args), + }), + } + }, + }), + delete: () => ({ + where: (...args: unknown[]) => deleteWhere(...args), + }), + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (left: unknown, right: unknown) => ({ eq: [left, right] }), + findOrFail: vi.fn(), + inArray: (left: unknown, right: unknown) => ({ inArray: [left, right] }), + isNotNull: (column: unknown) => ({ isNotNull: column }), + isNull: (column: unknown) => ({ isNull: column }), + notExists: (query: unknown) => ({ notExists: query }), + sql: (strings: TemplateStringsArray) => ({ sql: strings.join("?") }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + contactInboxModel: { + id: "ContactInbox.id", + contactId: "ContactInbox.contactId", + }, + contactModel: { id: "Contact.id", workspaceId: "Contact.workspaceId" }, + contactsToTagsModel: { + contactId: "ContactToTag.contactId", + tagId: "ContactToTag.tagId", + }, + contactToTagChannelModel: { + contactInboxId: "ContactToTagChannel.contactInboxId", + tagId: "ContactToTagChannel.tagId", + }, + tagModel: { + id: "Tag.id", + name: "Tag.name", + workspaceId: "Tag.workspaceId", + deletedAt: "Tag.deletedAt", + }, +})) + +vi.mock("@chatbotx.io/events", () => ({ + emitTagApplied: vi.fn(), + emitTagRemoved: vi.fn(), +})) + +vi.mock("../src/ads-conversion/service", () => ({ + adsConversionService: { + enqueueTagAppliedEvaluationsBulk: vi.fn(), + }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + invalidateCacheByTags: (...args: unknown[]) => invalidateCacheByTags(...args), + withCache: async (_key: string, callback: () => Promise) => + await callback(), +})) + +vi.mock("../src/contact", () => ({ + contactService: { findManyByIds: vi.fn() }, +})) + +vi.mock("../src/tag/sync.service", () => ({ + tagSyncService: { enqueueAttachMany: vi.fn() }, +})) + +const { tagService } = await import("../src/tag/service") + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("findManyByIds", () => { + test("returns [] without querying when ids is empty", async () => { + const result = await tagService.findManyByIds({ + workspaceId: "ws-1", + ids: [], + }) + expect(result).toEqual([]) + expect(findManyTag).not.toHaveBeenCalled() + }) + + // Mirrors the export-contacts query verbatim: the header lookup must not + // resurrect a soft-deleted tag's name, so `deletedAt: isNull` is part of the + // scope here (unlike `findById`, which deliberately omits it). + test("scopes by workspaceId and excludes soft-deleted tags", async () => { + findManyTag.mockResolvedValue([{ id: "t-1", name: "VIP" }]) + + const result = await tagService.findManyByIds({ + workspaceId: "ws-1", + ids: ["t-1"], + }) + + expect(result).toEqual([{ id: "t-1", name: "VIP" }]) + expect(findManyTag).toHaveBeenCalledWith({ + where: { + id: { in: ["t-1"] }, + workspaceId: "ws-1", + deletedAt: { isNull: true }, + }, + }) + }) +}) + +describe("findById", () => { + test("looks up by id + workspaceId with no deletedAt filter (unlike findByKey)", async () => { + findFirstTag.mockResolvedValue({ id: "t-1" }) + + const result = await tagService.findById({ + workspaceId: "ws-1", + id: "t-1", + }) + + expect(result).toEqual({ id: "t-1" }) + expect(findFirstTag).toHaveBeenCalledWith({ + where: { id: "t-1", workspaceId: "ws-1" }, + }) + }) +}) + +describe("findNameByIdForWorkspace", () => { + test("scopes by workspaceId AND deletedAt IS NULL (tag ids are globally unique)", async () => { + findFirstTag.mockResolvedValue({ name: "VIP" }) + + const result = await tagService.findNameByIdForWorkspace({ + workspaceId: "ws-1", + id: "t-1", + }) + + expect(result).toBe("VIP") + expect(findFirstTag).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: "t-1", workspaceId: "ws-1" }), + }), + ) + }) + + test("returns null when no match", async () => { + findFirstTag.mockResolvedValue(undefined) + const result = await tagService.findNameByIdForWorkspace({ + workspaceId: "ws-1", + id: "missing", + }) + expect(result).toBeNull() + }) +}) + +describe("hardDeleteSoftDeleted", () => { + test("keeps the isNotNull(deletedAt) guard so an un-deleted tag cannot be hard-deleted", async () => { + deleteWhere.mockResolvedValue(undefined) + + await tagService.hardDeleteSoftDeleted({ + workspaceId: "ws-1", + tagId: "t-1", + }) + + const whereArg = deleteWhere.mock.calls[0]?.[0] as { and: unknown[] } + const flat = JSON.stringify(whereArg) + expect(flat).toContain("isNotNull") + expect(invalidateCacheByTags).toHaveBeenCalled() + }) +}) + +describe("attachExistingToContactForTrigger", () => { + test("returns [] without inserting when tagIds is empty", async () => { + const result = await tagService.attachExistingToContactForTrigger({ + workspaceId: "ws-1", + contactId: "c-1", + tagIds: [], + }) + expect(result).toEqual([]) + expect(insertValues).not.toHaveBeenCalled() + }) + + test("returns [] without inserting when no candidate tags exist", async () => { + findManyTag.mockResolvedValue([]) + + const result = await tagService.attachExistingToContactForTrigger({ + workspaceId: "ws-1", + contactId: "c-1", + tagIds: ["t-1"], + }) + + expect(result).toEqual([]) + expect(insertValues).not.toHaveBeenCalled() + }) + + test("returns only the newly-linked pairs and does not emit tagApplied", async () => { + findManyTag.mockResolvedValue([{ id: "t-1" }, { id: "t-2" }]) + insertReturning.mockResolvedValue([{ tagId: "t-1" }]) + + const result = await tagService.attachExistingToContactForTrigger({ + workspaceId: "ws-1", + contactId: "c-1", + tagIds: ["t-1", "t-2"], + }) + + expect(result).toEqual([{ tagId: "t-1" }]) + // No events module call recorded for this path — the trigger action + // executor enqueues sync/ads evaluation itself per returned pair. + }) +}) + +describe("detachFromContactForTrigger", () => { + test("no-ops without deleting when tagIds is empty", async () => { + await tagService.detachFromContactForTrigger({ + workspaceId: "ws-1", + contactId: "c-1", + tagIds: [], + }) + expect(deleteWhere).not.toHaveBeenCalled() + }) + + test("plain-deletes the contact/tag links, scoped to contactId + tagIds", async () => { + deleteWhere.mockResolvedValue(undefined) + + await tagService.detachFromContactForTrigger({ + workspaceId: "ws-1", + contactId: "c-1", + tagIds: ["t-1"], + }) + + expect(deleteWhere).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/business/__tests__/trigger-service-executions.test.ts b/packages/business/__tests__/trigger-service-executions.test.ts new file mode 100644 index 0000000000..70c665a508 --- /dev/null +++ b/packages/business/__tests__/trigger-service-executions.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// triggerService — the execution/stats/purge surface moved from the trigger +// worker services: listActiveWithConditions, listActiveWithConditionsPage, +// listExecutedPairs, recordExecution, recordContactHistory, incrementStats, +// purgeExecutionsOlderThan. +// --------------------------------------------------------------------------- + +const findManyTrigger = vi.fn() +const findManyExecution = vi.fn() +const insertValues = vi.fn() +const insertOnConflictDoNothing = vi.fn() +const insertOnConflictDoUpdate = vi.fn() +const dbExecute = vi.fn() + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + triggerModel: { findMany: (...a: unknown[]) => findManyTrigger(...a) }, + triggerExecutionModel: { + findMany: (...a: unknown[]) => findManyExecution(...a), + }, + }, + insert: () => ({ + values: (values: unknown) => { + insertValues(values) + return { + onConflictDoNothing: (...a: unknown[]) => + insertOnConflictDoNothing(...a), + onConflictDoUpdate: (...a: unknown[]) => + insertOnConflictDoUpdate(...a), + } + }, + }), + delete: () => ({ where: vi.fn() }), + execute: (...a: unknown[]) => dbExecute(...a), + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + inArray: (a: unknown, b: unknown) => ({ inArray: [a, b] }), + sql: Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ + sql: [strings, values], + }), + { raw: vi.fn() }, + ), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + triggerModel: { id: "trigger.id", workspaceId: "trigger.workspaceId" }, + triggerContactHistoryModel: {}, + triggerExecutionModel: { + triggerId: "execution.triggerId", + contactId: "execution.contactId", + }, + triggerStatsModel: { + triggerId: "stats.triggerId", + date: "stats.date", + totalContacts: "stats.totalContacts", + totalExecutions: "stats.totalExecutions", + successCount: "stats.successCount", + failureCount: "stats.failureCount", + }, +})) + +// `trigger/service.ts` imports `triggerRepository` at module scope for the +// listPaginatedWithConditions/findWithConditions surface this file doesn't +// exercise. Left unmocked, the real `@chatbotx.io/database/repositories` +// barrel loads (pulling in contact-filter query builders that need the real +// schema) against the partial schema mock above and crashes at import time. +vi.mock("@chatbotx.io/database/repositories", () => ({ + triggerRepository: { + listPaginatedWithConditions: vi.fn(), + findWithConditions: vi.fn(), + }, +})) + +vi.mock("@chatbotx.io/events", () => ({ removeTriggerCache: vi.fn() })) +vi.mock("../src/template/installed-resource.service", () => ({ + assertDeletable: vi.fn(), +})) + +const { triggerService } = await import("../src/trigger/service") + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("listActiveWithConditions", () => { + test("scopes to workspace + active, with conditions relation", async () => { + findManyTrigger.mockResolvedValue([{ id: "tr-1", conditions: [] }]) + + const result = await triggerService.listActiveWithConditions({ + workspaceId: "ws-1", + }) + + expect(result).toEqual([{ id: "tr-1", conditions: [] }]) + expect(findManyTrigger).toHaveBeenCalledWith({ + where: { workspaceId: "ws-1", active: true }, + with: { conditions: true }, + }) + }) +}) + +describe("listActiveWithConditionsPage", () => { + test("returns nextCursor only when the page is full", async () => { + findManyTrigger.mockResolvedValue([{ id: "tr-1" }, { id: "tr-2" }]) + + const result = await triggerService.listActiveWithConditionsPage({ + limit: 2, + }) + + expect(result.nextCursor).toBe("tr-2") + }) + + test("omits nextCursor when the page is short (drained)", async () => { + findManyTrigger.mockResolvedValue([{ id: "tr-1" }]) + + const result = await triggerService.listActiveWithConditionsPage({ + limit: 2, + }) + + expect(result.nextCursor).toBeUndefined() + }) +}) + +describe("listExecutedPairs", () => { + test("returns [] without querying when either id list is empty", async () => { + const result = await triggerService.listExecutedPairs({ + triggerIds: [], + contactIds: ["c-1"], + }) + expect(result).toEqual([]) + expect(findManyExecution).not.toHaveBeenCalled() + }) +}) + +describe("recordExecution", () => { + test("onConflictDoNothing-inserts the execution row", async () => { + await triggerService.recordExecution({ + triggerId: "tr-1", + contactId: "c-1", + workspaceId: "ws-1", + }) + + expect(insertOnConflictDoNothing).toHaveBeenCalled() + }) +}) + +describe("purgeExecutionsOlderThan", () => { + test("returns the affected rowCount", async () => { + dbExecute.mockResolvedValue({ rowCount: 7 }) + + const result = await triggerService.purgeExecutionsOlderThan(new Date()) + + expect(result).toBe(7) + }) + + test("returns 0 when rowCount is null", async () => { + dbExecute.mockResolvedValue({ rowCount: null }) + const result = await triggerService.purgeExecutionsOlderThan(new Date()) + expect(result).toBe(0) + }) +}) + +describe("recordContactHistory", () => { + test("inserts a firstEnteredAt history row (plain insert, no conflict handling)", async () => { + await triggerService.recordContactHistory({ + triggerId: "tr-1", + contactId: "c-1", + workspaceId: "ws-1", + }) + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ triggerId: "tr-1", contactId: "c-1" }), + ) + }) +}) + +describe("incrementStats", () => { + test("upserts with the conflict target [triggerId, date] and +1 sql expressions", async () => { + await triggerService.incrementStats({ + triggerId: "tr-1", + workspaceId: "ws-1", + date: new Date("2026-01-01"), + success: true, + }) + + expect(insertOnConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: ["stats.triggerId", "stats.date"], + }), + ) + }) + + test("failure path increments failureCount, not successCount", async () => { + await triggerService.incrementStats({ + triggerId: "tr-1", + workspaceId: "ws-1", + date: new Date("2026-01-01"), + success: false, + }) + + const arg = insertOnConflictDoUpdate.mock.calls[0]?.[0] as { + set: { successCount: unknown; failureCount: unknown } + } + // successCount stays the column reference (no +1) on failure. + expect(arg.set.successCount).toBe("stats.successCount") + }) +}) diff --git a/packages/business/__tests__/user-quota-reconcile-self.test.ts b/packages/business/__tests__/user-quota-reconcile-self.test.ts new file mode 100644 index 0000000000..16d3c66bed --- /dev/null +++ b/packages/business/__tests__/user-quota-reconcile-self.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// userQuotaService — the non-reseller self-reconcile surface moved from +// sync-user-quota.ts: reconcileUserSelfUsage, persistMacUsed, +// applyMonthlyBotMessagesReset. AGENTS.md invariants 11/12: counts are +// assigned directly (never GREATEST); onConflictDoUpdate upserts must never +// become plain inserts. Mirrors the mock scaffolding in +// user-quota-bootstrap-plan.test.ts. +// --------------------------------------------------------------------------- + +const { + dbInsert, + dbSelect, + countDistinctTeamMembersForOwner, + findFirstUserQuota, + hset, + userQuotaModel, + countResults, +} = vi.hoisted(() => { + const userQuotaModel = { + userId: "userId-column", + contactsUsed: "contactsUsed-column", + workspacesUsed: "workspacesUsed-column", + channelsUsed: "channelsUsed-column", + teamMembersUsed: "teamMembersUsed-column", + macUsed: "macUsed-column", + monthlyBotMessagesUsed: "monthlyBotMessagesUsed-column", + monthlyBotMessagesPeriodStart: "monthlyBotMessagesPeriodStart-column", + } + const insertBuilder = { + values: vi.fn(), + onConflictDoUpdate: vi.fn(), + } + insertBuilder.values.mockReturnValue(insertBuilder) + insertBuilder.onConflictDoUpdate.mockReturnValue(Promise.resolve(undefined)) + + // Dequeued in the order reconcileUserSelfUsage issues them: + // [contacts, workspaces, channels]. teamMembers comes from + // countDistinctTeamMembersForOwner, not a select. + const countResults: number[] = [] + const selectBuilder = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(async () => [{ count: countResults.shift() ?? 3 }]), + } + selectBuilder.from.mockReturnValue(selectBuilder) + selectBuilder.innerJoin.mockReturnValue(selectBuilder) + + return { + dbInsert: vi.fn(() => insertBuilder), + dbSelect: vi.fn(() => selectBuilder), + countDistinctTeamMembersForOwner: vi.fn(async () => 2), + findFirstUserQuota: vi.fn(async () => ({ + macUsed: 10, + periodStart: new Date("2026-01-01T00:00:00Z"), + periodEnd: new Date("2026-02-01T00:00:00Z"), + monthlyBotMessagesPeriodStart: new Date("2026-01-01T00:00:00Z"), + })), + hset: vi.fn(async () => undefined), + userQuotaModel, + countResults, + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + insert: dbInsert, + select: dbSelect, + query: { userQuotaModel: { findFirst: findFirstUserQuota } }, + }, + and: (...args: unknown[]) => ({ and: args }), + count: vi.fn(() => "count()"), + countDistinct: vi.fn(), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + gt: vi.fn(), + lte: vi.fn(), + sql: Object.assign( + (strings: TemplateStringsArray) => ({ sql: strings.join("?") }), + { raw: vi.fn() }, + ), + sum: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ planStatuses: {} })) + +vi.mock("@chatbotx.io/database/schema", () => ({ + ROOT_TENANT_ID: "1", + contactModel: { workspaceId: "contact.workspaceId" }, + inboxModel: { workspaceId: "inbox.workspaceId" }, + userQuotaModel, + workspaceMacModel: {}, + workspaceMemberModel: {}, + workspaceModel: { id: "workspace.id", ownerId: "workspace.ownerId" }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + cacheConnections: { + useExisting: vi.fn(async () => ({ hset })), + }, + distributedStore: { + get: vi.fn(async () => null), + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }, + invalidateCacheByTags: vi.fn(async () => undefined), +})) + +vi.mock("../src/keys", () => ({ isCloud: vi.fn(() => true) })) +vi.mock("../src/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() } })) + +const { userQuotaService } = await import("../src/user-quota/service") + +beforeEach(() => { + vi.clearAllMocks() + countResults.length = 0 + countDistinctTeamMembersForOwner.mockResolvedValue(2) + findFirstUserQuota.mockResolvedValue({ + macUsed: 10, + periodStart: new Date("2026-01-01T00:00:00Z"), + periodEnd: new Date("2026-02-01T00:00:00Z"), + monthlyBotMessagesPeriodStart: new Date("2026-01-01T00:00:00Z"), + }) + vi.spyOn( + userQuotaService, + "countDistinctTeamMembersForOwner", + ).mockImplementation(countDistinctTeamMembersForOwner) +}) + +describe("reconcileUserSelfUsage", () => { + test("assigns counts directly (not GREATEST) via onConflictDoUpdate", async () => { + await userQuotaService.reconcileUserSelfUsage("user-1") + + const call = dbInsert.mock.results.find((r) => r.value)?.value as { + onConflictDoUpdate: ReturnType + } + expect(call.onConflictDoUpdate).toHaveBeenCalled() + const arg = call.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + // Assigned directly as plain numbers, never a GREATEST(...) sql fragment. + expect(typeof arg.set.contactsUsed).toBe("number") + expect(typeof arg.set.teamMembersUsed).toBe("number") + expect(typeof arg.set.workspacesUsed).toBe("number") + expect(typeof arg.set.channelsUsed).toBe("number") + }) + + // Relocated from apps/worker/__tests__/sync-user-quota-reconcile.test.ts, + // which can no longer see the upsert now that it mocks the service. + test("writes the recomputed count even when LOWER than the stored value (deletions free slots)", async () => { + // Source-of-truth counts after deletions: [contacts, workspaces, channels]. + countResults.push(3, 2, 4) + countDistinctTeamMembersForOwner.mockResolvedValue(1) + // The stored row previously held higher (high-water) values. + findFirstUserQuota.mockResolvedValue({ + macUsed: 0, + periodStart: null, + periodEnd: null, + monthlyBotMessagesPeriodStart: null, + }) + + await userQuotaService.reconcileUserSelfUsage("user-1") + + const call = dbInsert.mock.results.find((r) => r.value)?.value as { + onConflictDoUpdate: ReturnType + } + const arg = call.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + expect(arg.set.contactsUsed).toBe(3) + expect(arg.set.teamMembersUsed).toBe(1) + expect(arg.set.workspacesUsed).toBe(2) + expect(arg.set.channelsUsed).toBe(4) + + // The live Redis counter mirrors the current count, not the stale values. + expect(hset.mock.calls[0]).toEqual([ + expect.any(String), + "contacts", + "3", + "teamMembers", + "1", + "workspaces", + "2", + "channels", + "4", + ]) + }) + + test("counts a human shared across workspaces once", async () => { + // Two workspaces with the owner and one shared teammate produce four + // membership rows but only two distinct people. + countResults.push(0, 2, 0) + countDistinctTeamMembersForOwner.mockResolvedValue(2) + + await userQuotaService.reconcileUserSelfUsage("user-1") + + expect(countDistinctTeamMembersForOwner).toHaveBeenCalledWith("user-1") + const call = dbInsert.mock.results.find((r) => r.value)?.value as { + onConflictDoUpdate: ReturnType + } + const arg = call.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + expect(arg.set.teamMembersUsed).toBe(2) + }) + + test("writes increases too (count grew since last sync)", async () => { + countResults.push(42, 3, 5) + countDistinctTeamMembersForOwner.mockResolvedValue(7) + + await userQuotaService.reconcileUserSelfUsage("user-2") + + const call = dbInsert.mock.results.find((r) => r.value)?.value as { + onConflictDoUpdate: ReturnType + } + const arg = call.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + expect(arg.set.contactsUsed).toBe(42) + expect(arg.set.teamMembersUsed).toBe(7) + expect(arg.set.workspacesUsed).toBe(3) + expect(arg.set.channelsUsed).toBe(5) + }) + + test("hset field list has no `mac` field on the self-reconcile path", async () => { + await userQuotaService.reconcileUserSelfUsage("user-1") + + const hsetArgs = hset.mock.calls[0] as unknown[] + const fields = hsetArgs.slice(1).filter((_, i) => i % 2 === 0) + expect(fields).toEqual([ + "contacts", + "teamMembers", + "workspaces", + "channels", + ]) + expect(fields).not.toContain("mac") + }) + + test("returns the four markers from one round-trip read-back", async () => { + const result = await userQuotaService.reconcileUserSelfUsage("user-1") + + expect(result).toEqual({ + macUsed: 10, + periodStart: new Date("2026-01-01T00:00:00Z"), + periodEnd: new Date("2026-02-01T00:00:00Z"), + monthlyBotMessagesPeriodStart: new Date("2026-01-01T00:00:00Z"), + }) + }) + + test("returns zeroed/null markers when no quota row exists yet", async () => { + findFirstUserQuota.mockResolvedValue(undefined) + + const result = await userQuotaService.reconcileUserSelfUsage("user-1") + + expect(result).toEqual({ + macUsed: 0, + periodStart: null, + periodEnd: null, + monthlyBotMessagesPeriodStart: null, + }) + }) +}) + +describe("persistMacUsed", () => { + test("upserts macUsed to the given absolute value via onConflictDoUpdate", async () => { + await userQuotaService.persistMacUsed("user-1", 42) + + const call = dbInsert.mock.results.at(-1)?.value as { + values: ReturnType + onConflictDoUpdate: ReturnType + } + expect(call.values).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", macUsed: 42 }), + ) + expect(call.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ macUsed: 42 }), + }), + ) + }) +}) + +describe("applyMonthlyBotMessagesReset", () => { + test("reset branch zeroes monthlyBotMessagesUsed and stamps the period", async () => { + await userQuotaService.applyMonthlyBotMessagesReset({ + userId: "user-1", + periodStart: new Date("2026-02-01T00:00:00Z"), + reset: true, + }) + + const call = dbInsert.mock.results.at(-1)?.value as { + values: ReturnType + } + expect(call.values).toHaveBeenCalledWith( + expect.objectContaining({ monthlyBotMessagesUsed: 0 }), + ) + }) + + test("non-reset branch stamps only, without touching the counter", async () => { + await userQuotaService.applyMonthlyBotMessagesReset({ + userId: "user-1", + periodStart: new Date("2026-02-01T00:00:00Z"), + reset: false, + }) + + const call = dbInsert.mock.results.at(-1)?.value as { + values: ReturnType + } + const valuesArg = call.values.mock.calls[0]?.[0] as Record + expect(valuesArg).not.toHaveProperty("monthlyBotMessagesUsed") + expect(valuesArg.monthlyBotMessagesPeriodStart).toEqual( + new Date("2026-02-01T00:00:00Z"), + ) + }) +}) diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index 860e8a9e72..42c79bec0b 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -66,6 +66,7 @@ import { import type { BroadcastModel, BroadcastTargetModel, + ConversationModel, FlowModel, InboxModel, IntegrationMessengerModel, @@ -2244,6 +2245,191 @@ class BroadcastService extends BaseService { return newBroadcast } + + /** `enqueue-broadcast.ts`: scheduled broadcasts due to fan out into prepareBroadcast jobs. */ + async listDueScheduled(input: { dueAt: Date }): Promise<{ id: string }[]> { + return await db.query.broadcastModel.findMany({ + where: { + schedulesAt: { lte: input.dueAt }, + status: broadcastStatuses.enum.scheduled, + deletedAt: { isNull: true }, + }, + columns: { id: true }, + }) + } + + /** `reconcile-broadcasts.ts`: sending broadcasts still awaiting handoff completion. */ + async listSendingAwaitingHandoff(): Promise<{ id: string }[]> { + return await db.query.broadcastModel.findMany({ + where: { + status: broadcastStatuses.enum.sending, + handoffCompletedAt: { isNull: true }, + deletedAt: { isNull: true }, + }, + columns: { id: true }, + }) + } + + /** `prepare-broadcast.ts`: the full row for the broadcast this prepare run is dispatching. */ + async findScheduledForPrepare(input: { + broadcastId: string + }): Promise< + (BroadcastModel & { targets: { inboxId: string }[] }) | undefined + > { + return await db.query.broadcastModel.findFirst({ + where: { + id: input.broadcastId, + status: broadcastStatuses.enum.scheduled, + deletedAt: { isNull: true }, + }, + with: { targets: { columns: { inboxId: true } } }, + }) + } + + /** + * `prepare-broadcast.ts`: resolves the page a Messenger template belongs + * to, scoped to the workspace via the nested `integrationMessenger` + * relation filter — that nested filter IS the tenant scope, keep it + * verbatim. + */ + async resolveTemplateIntegrationMessengerId(input: { + workspaceId: string + templateId: string + }): Promise { + const template = await db.query.messengerMessageTemplateModel.findFirst({ + where: { + id: input.templateId, + integrationMessenger: { workspaceId: input.workspaceId }, + }, + columns: { integrationMessengerId: true }, + }) + return template?.integrationMessengerId ?? null + } + + /** `prepare-broadcast.ts`: bulk-insert the resolved audience's recipient rows. */ + async insertRecipients(input: { + recipients: { + broadcastId: string + contactId: string + contactInboxId: string + conversationId: string + }[] + }): Promise { + if (input.recipients.length === 0) { + return + } + await db + .insert(contactsOnBroadcastsModel) + .values(input.recipients) + .onConflictDoNothing() + } + + /** + * `prepare-broadcast.ts`: the CAS promotion out of `scheduled` once the + * audience has been built. `eq(resumeCount, promotionEpoch)` is the only + * guard against a stale prepare run (behind a moveToDraft → re-schedule + * round-trip) wrongly promoting a newer schedule's row — never drop it. + */ + async promoteAfterPrepare(input: { + broadcastId: string + status: BroadcastStatus + contactCount: number + promotionEpoch: number + }): Promise { + const rows = await db + .update(broadcastModel) + .set({ + status: broadcastStatuses.enum[input.status], + contactCount: input.contactCount, + }) + .where( + and( + eq(broadcastModel.id, input.broadcastId), + eq(broadcastModel.status, broadcastStatuses.enum.scheduled), + isNull(broadcastModel.deletedAt), + eq(broadcastModel.resumeCount, input.promotionEpoch), + ), + ) + .returning({ id: broadcastModel.id }) + return rows.length > 0 + } + + /** + * `process-broadcast-contacts.ts`: every `sending` row for one broadcast — + * returns an array (the handler checks `.length === 0`), distinct from + * `findSendableBroadcast` which returns a single `{id}` row. + */ + async listSendableById(input: { + broadcastId: string + }): Promise { + return await db.query.broadcastModel.findMany({ + where: { + id: input.broadcastId, + status: broadcastStatuses.enum.sending, + deletedAt: { isNull: true }, + }, + with: { + targets: { + columns: { + inboxId: true, + flowId: true, + templateId: true, + templateData: true, + }, + }, + }, + }) + } + + /** `process-broadcast-contacts.ts`: the next page of unsent, unfailed recipients. */ + async listPendingRecipients(input: { + broadcastId: string + limit: number + }): Promise { + return (await db.query.contactsOnBroadcastsModel.findMany({ + where: { + broadcastId: input.broadcastId, + sent: false, + failedAt: { isNull: true }, + }, + with: { + conversation: true, + contactInbox: true, + }, + limit: input.limit, + })) as BroadcastRecipientForSend[] + } + + /** `process-broadcast-contacts.ts`: marks one recipient failed with a reason. */ + async markContactFailed(input: { + broadcastId: string + contactId: string + reason: string + }): Promise { + await db + .update(contactsOnBroadcastsModel) + .set({ + failedAt: sql`CURRENT_TIMESTAMP`, + errorContent: input.reason, + }) + .where( + and( + eq(contactsOnBroadcastsModel.broadcastId, input.broadcastId), + eq(contactsOnBroadcastsModel.contactId, input.contactId), + ), + ) + } } export const broadcastService = new BroadcastService() + +export type BroadcastForSend = Awaited< + ReturnType<(typeof db.query.broadcastModel)["findMany"]> +>[number] + +export type BroadcastRecipientForSend = Awaited< + ReturnType<(typeof db.query.contactsOnBroadcastsModel)["findMany"]> +>[number] & { + conversation?: ConversationModel | null + contactInbox?: ContactInboxRow | null +} diff --git a/packages/business/src/contact/index.ts b/packages/business/src/contact/index.ts index 82a849db97..a14c0cafe8 100644 --- a/packages/business/src/contact/index.ts +++ b/packages/business/src/contact/index.ts @@ -1,5 +1,6 @@ export * from "./contact-info-changes" export * from "./extract-contact" +export * from "./insert-imported-batch" export * from "./profile-refresh" export * from "./service" export * from "./update-from-message" diff --git a/packages/business/src/contact/insert-imported-batch.ts b/packages/business/src/contact/insert-imported-batch.ts new file mode 100644 index 0000000000..79bbea6414 --- /dev/null +++ b/packages/business/src/contact/insert-imported-batch.ts @@ -0,0 +1,159 @@ +import { db, inArray } from "@chatbotx.io/database/client" +import { contactSources } from "@chatbotx.io/database/partials" +import { + contactInboxModel, + contactModel, + contactsToTagsModel, + conversationModel, +} from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" +// contact-custom-field/service.ts imports contactService back from +// ../contact/service, a pre-existing cycle on main; this import re-enters it +// via this module. Known and accepted — see PR #1101 review, I3. +import { contactCustomFieldService } from "../contact-custom-field/service" +import { messageCleanupService } from "../message-cleanup/service" + +export type InsertImportedContactBatchInput = { + workspaceId: string + inbox: { id: string; channel: string } + accepted: { + contactId: string + contactInboxId: string + row: { + externalId?: string | null + sourceUserId?: string | null + phoneNumber?: string | null + email?: string | null + firstName?: string | null + lastName?: string | null + customFields: { customFieldId: string; value: string }[] + } + }[] + tagId?: string +} + +export type InsertImportedContactBatchResult = { + inserted: number + orphanCount: number +} + +/** + * Moved verbatim from `imports/handler/contacts/handler.ts` `insertContactBatch`'s + * `db.transaction` body: the same insert-then-onConflictDoNothing survivor + * logic, the orphan prune, the conversation insert, and the normalized + * custom-field insert. The only behavior change is that the `logger.warn` + * about conflicts moved OUT of the transaction — this returns `orphanCount` + * and the caller logs it, so the transaction itself never logs. + */ +export async function insertImportedContactBatch( + input: InsertImportedContactBatchInput, +): Promise { + const { workspaceId, inbox, accepted, tagId } = input + + if (accepted.length === 0) { + return { inserted: 0, orphanCount: 0 } + } + + return await db.transaction(async (tx) => { + await tx.insert(contactModel).values( + accepted.map(({ contactId, row }) => ({ + id: contactId, + workspaceId, + phoneNumber: row.phoneNumber, + email: row.email, + firstName: row.firstName, + lastName: row.lastName, + })), + ) + + // A duplicate should already have been removed by the caller's re-check, + // but a non-import path (e.g. a concurrent inbound message creating the + // same (inboxId, sourceId)) can still win the race in the window between + // that re-check and this insert. `onConflictDoNothing` skips those rows; + // we then continue with only the contacts whose link actually inserted, + // so a single late conflict can no longer fail the entire batch while + // still guaranteeing no contact is created without its inbox row. + const insertedContactInboxes = await tx + .insert(contactInboxModel) + .values( + accepted.map(({ contactId, contactInboxId, row }) => { + // externalId is guaranteed non-null here by the caller, but assert + // explicitly rather than casting to catch future regressions. + if (!row.externalId) { + throw new Error("Invariant: externalId must be set before insert") + } + return { + id: contactInboxId, + originalContactId: contactId, + contactId, + inboxId: inbox.id, + channel: inbox.channel, + source: contactSources.enum.imported, + sourceId: row.externalId, + sourceUserId: row.sourceUserId ?? null, + } + }), + ) + .onConflictDoNothing() + .returning({ contactId: contactInboxModel.contactId }) + + const insertedContactIds = new Set( + insertedContactInboxes.map((inboxRow) => inboxRow.contactId), + ) + const survivors = accepted.filter(({ contactId }) => + insertedContactIds.has(contactId), + ) + + // 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: survivors.flatMap(({ row }) => + row.externalId ? [row.externalId] : [], + ), + tx, + }) + + // Prune the orphan Contact rows whose link lost the conflict so we never + // leave a contact without a channel row (cascades clean up any partial + // children). + let orphanCount = 0 + if (survivors.length !== accepted.length) { + const orphanIds = accepted + .filter(({ contactId }) => !insertedContactIds.has(contactId)) + .map(({ contactId }) => contactId) + await tx.delete(contactModel).where(inArray(contactModel.id, orphanIds)) + orphanCount = orphanIds.length + } + + if (survivors.length === 0) { + return { inserted: 0, orphanCount } + } + + await tx.insert(conversationModel).values( + survivors.map(({ contactId }) => ({ + id: createId(), + workspaceId, + contactId, + })), + ) + + await contactCustomFieldService.insertNormalizedValuesForNewContacts({ + workspaceId, + entries: survivors.map(({ contactId, row }) => ({ + contactId, + fields: row.customFields, + })), + tx, + }) + + if (tagId) { + await tx + .insert(contactsToTagsModel) + .values(survivors.map(({ contactId }) => ({ contactId, tagId }))) + .onConflictDoNothing() + } + + return { inserted: survivors.length, orphanCount } + }) +} diff --git a/packages/business/src/contact/service.ts b/packages/business/src/contact/service.ts index b8ba341f01..db34bbe356 100644 --- a/packages/business/src/contact/service.ts +++ b/packages/business/src/contact/service.ts @@ -46,6 +46,11 @@ import { workspaceService } from "../workspace/service" import { workspaceUsageService } from "../workspace-usage/service" import { emitContactInfoChangeEvents } from "./contact-info-changes" import { createContactWithInbox } from "./create-with-inbox" +import { + type InsertImportedContactBatchInput, + type InsertImportedContactBatchResult, + insertImportedContactBatch, +} from "./insert-imported-batch" import { type ContactListScope as ContactListScopeType, count as countContacts, @@ -115,7 +120,7 @@ export type ContactAccessScope = { export type ContactListScope = ContactListScopeType -class ContactService extends BaseService { +export class ContactService extends BaseService { createWithInbox = createContactWithInbox updateFieldsAndCustomFields = updateFieldsAndCustomFields list = listContacts @@ -230,6 +235,7 @@ class ContactService extends BaseService { }) { await contactService.unblock(ctx) } + // ─── Legacy generic find (preserved for backward compat) ──────────────── async findBy(props: { tx?: DatabaseClient @@ -977,6 +983,16 @@ class ContactService extends BaseService { ), ) } + + /** + * Bulk-insert a validated batch of imported contacts. Body lives in + * `./insert-imported-batch` to keep this file's merge surface small. + */ + insertImportedContactBatch( + input: InsertImportedContactBatchInput, + ): Promise { + return insertImportedContactBatch(input) + } } function richSystemFieldToContactData( diff --git a/packages/business/src/conversation/service.ts b/packages/business/src/conversation/service.ts index 2a508bc81c..870257a4de 100644 --- a/packages/business/src/conversation/service.ts +++ b/packages/business/src/conversation/service.ts @@ -1694,6 +1694,24 @@ class ConversationService extends BaseService { ), ) } + + /** + * `trigger/services/action-executor.ts` execute: the trigger action's own + * conversation lookup, ordered by `createdAt` desc and workspace-scoped. + * Distinct from `findLatestByContact` (orders by `lastActivityAt`, not + * workspace-scoped) — do not reuse that one here. + */ + async findLatestCreatedByContact(props: { + workspaceId: string + contactId: string + tx?: DatabaseClient + }): Promise { + const { tx = db, workspaceId, contactId } = props + return await tx.query.conversationModel.findFirst({ + where: { contactId, workspaceId }, + orderBy: { createdAt: "desc" }, + }) + } } export const conversationService = new ConversationService() diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index 4704c25ff9..f5fc9e96fe 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -573,7 +573,8 @@ class FlowService extends BaseService { /** * Active flow by id, scoped to workspace. Used by worker's - * `detectFlowVersion` to resolve the current version off `currentVersionId`. + * `detectFlowVersion` to resolve the current version off `currentVersionId`, + * and by `trigger/services/action-executor.ts` startAnotherFlow. */ async findActiveById(props: { id: string diff --git a/packages/business/src/integration-zalo/service.ts b/packages/business/src/integration-zalo/service.ts index 1f1557185d..55dbcd5c77 100644 --- a/packages/business/src/integration-zalo/service.ts +++ b/packages/business/src/integration-zalo/service.ts @@ -248,6 +248,29 @@ class ZaloIntegrationService extends BaseService { } await db.transaction(run) } + + /** + * Unscoped single-row lookup by id — `sync-channel-labels.ts` / `sync- + * tag.ts` resolve the integration first and only then know its workspace, + * so no `workspaceId` filter is available at this call site. Distinct + * name from `findById` above, which requires `workspaceId`. + */ + async findByIdUnscoped(props: { + id: string + }): Promise { + const row = await db.query.integrationZaloModel.findFirst({ + where: { id: props.id }, + }) + return row ?? null + } + + /** `sync-tag.ts` attach path: resolve the Zalo integration owning an inbox. */ + async findByInboxId(props: { inboxId: string }) { + const row = await db.query.integrationZaloModel.findFirst({ + where: { inboxId: props.inboxId }, + }) + return row ?? null + } } export const zaloIntegrationService = new ZaloIntegrationService() diff --git a/packages/business/src/tag/service.ts b/packages/business/src/tag/service.ts index c39c5ae8d8..cf8bdb5709 100644 --- a/packages/business/src/tag/service.ts +++ b/packages/business/src/tag/service.ts @@ -5,6 +5,7 @@ import { eq, findOrFail, inArray, + isNotNull, isNull, notExists, notInArray, @@ -1058,6 +1059,161 @@ class TagService extends BaseService { ) } + /** + * `export-contacts.ts` buildSelectedFields: tag name map for the export's + * selected tag columns. Scoped by workspace + `deletedAt IS NULL` — moved + * verbatim from the handler's inline query. + */ + async findManyByIds(props: { + workspaceId: string + ids: string[] + tx?: DatabaseClient + }): Promise { + const { workspaceId, ids, tx = db } = props + if (ids.length === 0) { + return [] + } + return await tx.query.tagModel.findMany({ + where: { + id: { in: ids }, + workspaceId, + deletedAt: { isNull: true as const }, + }, + }) + } + + /** + * Unscoped-by-deletedAt single-tag lookup, distinct from the cached + * `findByKey`: import-handler and sync-tag call sites need the tag row + * (including a soft-deleted one) purely by id, with no `deletedAt` filter + * and no cache. + */ + async findById(props: { + workspaceId: string + id: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, tx = db } = props + return await tx.query.tagModel.findFirst({ + where: { id, workspaceId }, + }) + } + + /** + * Workspace-scoped tag name lookup used by the webhook payload builder. + * Tag ids are globally unique, so an id-only lookup would leak another + * tenant's tag name into this workspace's outbound payload — the + * workspace + `deletedAt IS NULL` scope here is load-bearing. + */ + async findNameByIdForWorkspace(props: { + workspaceId: string + id: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, tx = db } = props + const tag = await tx.query.tagModel.findFirst({ + where: { id, workspaceId, deletedAt: { isNull: true as const } }, + columns: { name: true }, + }) + return tag?.name ?? null + } + + /** + * `sync-tag.ts` full workspace delete: hard-deletes a Tag row that is + * already soft-deleted. The `isNotNull(deletedAt)` guard is what stops an + * un-deleted tag from being hard-deleted — never drop it. Mirrors the + * cache tags `softDelete` invalidates. + */ + async hardDeleteSoftDeleted(props: { + workspaceId: string + tagId: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, tagId, tx = db } = props + await tx + .delete(tagModel) + .where( + and( + eq(tagModel.id, tagId), + eq(tagModel.workspaceId, workspaceId), + isNotNull(tagModel.deletedAt), + ), + ) + + await this.invalidateCacheTags([ + `tags:${workspaceId}`, + `tags:${workspaceId}:${tagId}`, + ]) + } + + /** + * Trigger-action tag attach: links tags to a contact and returns only the + * newly-linked pairs. Deliberately does **not** emit `tagApplied` or call + * `enqueueTagAppliedEvaluationsBulk` (unlike `attachToContact`) — the + * trigger action-executor enqueues `tagSyncService.enqueueAttach` and + * `adsConversionService.enqueueTagAppliedEvaluations` itself, per pair, so + * duplicating that here would double-fire channel sync and ads evaluation. + */ + async attachExistingToContactForTrigger(props: { + workspaceId: string + contactId: string + tagIds: string[] + tx?: DatabaseClient + }): Promise<{ tagId: string }[]> { + const { workspaceId, contactId, tagIds, tx = db } = props + if (tagIds.length === 0) { + return [] + } + + const existingTags = await tx.query.tagModel.findMany({ + where: { + id: { in: tagIds }, + workspaceId, + deletedAt: { isNull: true as const }, + }, + }) + + if (existingTags.length === 0) { + return [] + } + + return await tx + .insert(contactsToTagsModel) + .values( + existingTags.map((tag) => ({ + contactId, + tagId: tag.id, + })), + ) + .onConflictDoNothing() + .returning({ tagId: contactsToTagsModel.tagId }) + } + + /** + * Trigger-action tag detach: plain delete, no `emitTagRemoved`. The trigger + * action-executor enqueues `tagSyncService.enqueueDetach` itself per tag — + * unlike `detachFromContact`, this must not emit here too. + */ + async detachFromContactForTrigger(props: { + workspaceId: string + contactId: string + tagIds: string[] + tx?: DatabaseClient + }): Promise { + const { contactId, tagIds, tx = db } = props + if (tagIds.length === 0) { + return + } + await tx + .delete(contactsToTagsModel) + .where( + and( + eq(contactsToTagsModel.contactId, contactId), + inArray(contactsToTagsModel.tagId, tagIds), + ), + ) + } + /** * Link a workspace tag to many contacts, returning the NEWLY-linked * contact ids (untargeted `onConflictDoNothing()` — verbatim from diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index a28307f9a4..8194633749 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -1,8 +1,14 @@ -import { and, db, eq, inArray } from "@chatbotx.io/database/client" +import { and, db, eq, inArray, sql } from "@chatbotx.io/database/client" import type { FolderType } from "@chatbotx.io/database/partials" import { triggerRepository } from "@chatbotx.io/database/repositories" -import { conditionModel, triggerModel } from "@chatbotx.io/database/schema" -import type { TriggerModel } from "@chatbotx.io/database/types" +import { + conditionModel, + triggerContactHistoryModel, + triggerExecutionModel, + triggerModel, + triggerStatsModel, +} from "@chatbotx.io/database/schema" +import type { ConditionModel, TriggerModel } from "@chatbotx.io/database/types" import { removeTriggerCache, updateTriggerCache } from "@chatbotx.io/events" import { createId } from "@chatbotx.io/utils" import { isSameJsonValue } from "../audit/diff" @@ -18,6 +24,17 @@ import { MAX_TRIGGERS_PER_WORKSPACE } from "./constants" export type { ConditionInput } from "./condition-columns" +/** `trigger-matcher.service.ts` shape: an active trigger with its conditions. */ +export type TriggerWithConditions = TriggerModel & { + conditions: ConditionModel[] +} + +/** `datetime-trigger-evaluator.ts` fetchTriggerChunk page shape. */ +export type ActiveTriggerWithConditionsPageRow = TriggerModel & { + conditions: ConditionModel[] + workspace: { timezone: string | null } | null +} + class TriggerService extends BaseService { async create(input: { workspaceId: string @@ -358,6 +375,156 @@ class TriggerService extends BaseService { return withConditions ?? { ...trigger, conditions: [] } } + + /** `trigger-matcher.service.ts` findMatchingTriggers: every active trigger in a workspace, with conditions. */ + async listActiveWithConditions(input: { + workspaceId: string + }): Promise { + return (await db.query.triggerModel.findMany({ + where: { + workspaceId: input.workspaceId, + active: true, + }, + with: { + conditions: true, + }, + })) as TriggerWithConditions[] + } + + /** + * `datetime-trigger-evaluator.ts` fetchTriggerChunk page: every active + * trigger (with its conditions and workspace timezone), keyset-paginated + * by id. The datetime-condition filtering the handler applies afterward + * stays in the handler — this only returns the raw page. + */ + async listActiveWithConditionsPage(input: { + cursor?: string + limit: number + }): Promise<{ + triggers: ActiveTriggerWithConditionsPageRow[] + nextCursor: string | undefined + }> { + const triggers = (await db.query.triggerModel.findMany({ + where: { + active: true, + ...(input.cursor ? { id: { gt: input.cursor } } : {}), + }, + with: { + conditions: true, + workspace: true, + }, + limit: input.limit, + orderBy: { id: "asc" }, + })) as ActiveTriggerWithConditionsPageRow[] + + return { + triggers, + nextCursor: + triggers.length === input.limit ? triggers.at(-1)?.id : undefined, + } + } + + /** `datetime-trigger-evaluator.ts` getExecutedTriggers: existing (triggerId, contactId) execution pairs. */ + async listExecutedPairs(input: { + triggerIds: string[] + contactIds: string[] + }): Promise<{ triggerId: string; contactId: string }[]> { + if (input.triggerIds.length === 0 || input.contactIds.length === 0) { + return [] + } + return await db.query.triggerExecutionModel.findMany({ + where: { + triggerId: { in: input.triggerIds }, + contactId: { in: input.contactIds }, + }, + columns: { + triggerId: true, + contactId: true, + }, + }) + } + + /** `datetime-trigger-evaluator.ts` markTriggerExecuted: records a one-shot datetime execution. */ + async recordExecution(input: { + triggerId: string + contactId: string + workspaceId: string + }): Promise { + await db + .insert(triggerExecutionModel) + .values({ + id: createId(), + triggerId: input.triggerId, + contactId: input.contactId, + workspaceId: input.workspaceId, + createdAt: new Date(), + executedAt: new Date(), + }) + .onConflictDoNothing() + } + + /** `datetime-trigger-evaluator.ts` cleanupOldExecutions: purge executions older than the cutoff. */ + async purgeExecutionsOlderThan(cutoff: Date): Promise { + const result = await db.execute( + sql`DELETE FROM "TriggerExecution" WHERE "executedAt" < ${cutoff}`, + ) + return Number(result.rowCount ?? 0) + } + + /** `trigger-executor.service.ts` execute: records first-entered contact history. */ + async recordContactHistory(input: { + triggerId: string + contactId: string + workspaceId: string + }): Promise { + await db.insert(triggerContactHistoryModel).values({ + id: createId(), + triggerId: input.triggerId, + contactId: input.contactId, + workspaceId: input.workspaceId, + firstEnteredAt: new Date(), + }) + } + + /** + * `trigger-executor.service.ts` updateStats: daily per-trigger stats + * upsert. `+1` expressions and the conditional success/failure increment + * are moved verbatim; the date normalisation (`setHours(0,0,0,0)`) is the + * caller's responsibility. + */ + async incrementStats(input: { + triggerId: string + workspaceId: string + date: Date + success: boolean + }): Promise { + const { triggerId, workspaceId, date, success } = input + await db + .insert(triggerStatsModel) + .values({ + id: createId(), + triggerId, + workspaceId, + date, + totalContacts: 1, + totalExecutions: 1, + successCount: success ? 1 : 0, + failureCount: success ? 0 : 1, + }) + .onConflictDoUpdate({ + target: [triggerStatsModel.triggerId, triggerStatsModel.date], + set: { + totalContacts: sql`${triggerStatsModel.totalContacts} + 1`, + totalExecutions: sql`${triggerStatsModel.totalExecutions} + 1`, + successCount: success + ? sql`${triggerStatsModel.successCount} + 1` + : triggerStatsModel.successCount, + failureCount: success + ? triggerStatsModel.failureCount + : sql`${triggerStatsModel.failureCount} + 1`, + }, + }) + } } export const triggerService = new TriggerService() diff --git a/packages/business/src/user-quota/service.ts b/packages/business/src/user-quota/service.ts index 4f2422f287..ac39a55b98 100644 --- a/packages/business/src/user-quota/service.ts +++ b/packages/business/src/user-quota/service.ts @@ -888,6 +888,188 @@ class UserQuotaService extends BaseService { return { limit: null, used: 0 } } } + + /** + * `sync-user-quota.ts` reconcileUser (non-reseller path): re-grounds a + * plain user's own `UserQuota.*Used` from the source-of-truth DB counts — + * modeled directly on `reconcileOwnerPoolUsage` above (same `Promise.all` + * shape, same "assigned directly, NOT GREATEST" semantics so deletions + * free quota). Unlike the pool path, the live-counter `hset` here has NO + * `mac` field — mac is reconciled separately by the caller via + * `persistMacUsed`/the ledger reconcile, using the markers this method + * returns from one round-trip (`macUsed`, `periodStart`, `periodEnd`, + * `monthlyBotMessagesPeriodStart`). + */ + async reconcileUserSelfUsage(userId: string): Promise<{ + macUsed: number + periodStart: Date | null + periodEnd: Date | null + monthlyBotMessagesPeriodStart: Date | null + }> { + const client = await cacheConnections.useExisting() + + const [ + [contactsResult], + teamMembersUsed, + [workspacesResult], + [channelsResult], + ] = await Promise.all([ + db + .select({ count: count() }) + .from(contactModel) + .innerJoin( + workspaceModel, + eq(contactModel.workspaceId, workspaceModel.id), + ) + .where(eq(workspaceModel.ownerId, userId)), + + this.countDistinctTeamMembersForOwner(userId), + + db + .select({ count: count() }) + .from(workspaceModel) + .where(eq(workspaceModel.ownerId, userId)), + + db + .select({ count: count() }) + .from(inboxModel) + .innerJoin( + workspaceModel, + eq(inboxModel.workspaceId, workspaceModel.id), + ) + .where(eq(workspaceModel.ownerId, userId)), + ]) + + const contactsUsed = contactsResult?.count ?? 0 + const workspacesUsed = workspacesResult?.count ?? 0 + const channelsUsed = channelsResult?.count ?? 0 + + await db + .insert(userQuotaModel) + .values({ + userId, + contactsUsed, + teamMembersUsed, + workspacesUsed, + channelsUsed, + syncedAt: new Date(), + }) + .onConflictDoUpdate({ + target: userQuotaModel.userId, + set: { + // Authoritative current count from the source tables (already + // reflects deletions). Assigned directly — NOT GREATEST — so + // removing contacts, team members, workspaces, or channels frees + // quota. + contactsUsed, + teamMembersUsed, + workspacesUsed, + channelsUsed, + syncedAt: new Date(), + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + + // Mirror the live counters to the same authoritative current counts. + // No `mac` field here — this path's mac reconcile is separate. + await client.hset( + this.store.liveKey(userId), + "contacts", + String(contactsUsed), + "teamMembers", + String(teamMembersUsed), + "workspaces", + String(workspacesUsed), + "channels", + String(channelsUsed), + ) + + const stored = await db.query.userQuotaModel.findFirst({ + where: { userId }, + columns: { + macUsed: true, + periodStart: true, + periodEnd: true, + monthlyBotMessagesPeriodStart: true, + }, + }) + + return { + macUsed: stored?.macUsed ?? 0, + periodStart: stored?.periodStart ?? null, + periodEnd: stored?.periodEnd ?? null, + monthlyBotMessagesPeriodStart: + stored?.monthlyBotMessagesPeriodStart ?? null, + } + } + + /** `sync-user-quota.ts` persistMacUsed: upsert `UserQuota.macUsed` to an absolute value. */ + async persistMacUsed(userId: string, value: number): Promise { + await db + .insert(userQuotaModel) + .values({ userId, macUsed: value, syncedAt: new Date() }) + .onConflictDoUpdate({ + target: userQuotaModel.userId, + set: { + macUsed: value, + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + } + + /** + * `sync-user-quota.ts` reconcileMonthlyBotMessages: applies the resolved + * reset/stamp decision. The `reset` branch zeroes `monthlyBotMessagesUsed` + * and stamps the period; the else branch stamps only (adopt-into-current- + * period for an unstamped row, without touching the counter). Ordering is + * load-bearing: the DB counter is zeroed BEFORE the live Redis field, so a + * crash between the two writes fails closed (briefly over-blocks) rather + * than open — the caller must still write the live + * `monthlyBotMessages` hash field AFTER calling this, in that order. + */ + async applyMonthlyBotMessagesReset(input: { + userId: string + periodStart: Date | null + reset: boolean + }): Promise { + const { userId, periodStart, reset } = input + + if (reset) { + await db + .insert(userQuotaModel) + .values({ + userId, + monthlyBotMessagesUsed: 0, + monthlyBotMessagesPeriodStart: periodStart, + syncedAt: new Date(), + }) + .onConflictDoUpdate({ + target: userQuotaModel.userId, + set: { + monthlyBotMessagesUsed: 0, + monthlyBotMessagesPeriodStart: periodStart, + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + return + } + + // Unstamped row: adopt into the current period without touching the counter. + await db + .insert(userQuotaModel) + .values({ + userId, + monthlyBotMessagesPeriodStart: periodStart, + syncedAt: new Date(), + }) + .onConflictDoUpdate({ + target: userQuotaModel.userId, + set: { + monthlyBotMessagesPeriodStart: periodStart, + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + } } export const userQuotaService = new UserQuotaService() diff --git a/packages/business/src/webhook/service.ts b/packages/business/src/webhook/service.ts index 30a97b02b9..e16007f822 100644 --- a/packages/business/src/webhook/service.ts +++ b/packages/business/src/webhook/service.ts @@ -5,7 +5,7 @@ import { listWebhooksPaginated, } from "@chatbotx.io/database/repositories" import { conditionModel, webhookModel } from "@chatbotx.io/database/schema" -import type { WebhookModel } from "@chatbotx.io/database/types" +import type { ConditionModel, WebhookModel } from "@chatbotx.io/database/types" import { removeWebhookCache, updateWebhookCache } from "@chatbotx.io/events" import { distributedLock } from "@chatbotx.io/redis" import { createId } from "@chatbotx.io/utils" @@ -32,6 +32,11 @@ export type WebhookConditionInput = { value?: unknown } +/** `webhook-matcher.service.ts` shape: an active webhook with its conditions. */ +export type WebhookWithConditions = WebhookModel & { + conditions: ConditionModel[] +} + class WebhookService extends BaseService { /** * SQL-paginated webhook list with conditions joined in — shared by the @@ -381,6 +386,21 @@ class WebhookService extends BaseService { await this.audit("update", detail) } + + /** `webhook-matcher.service.ts` findAndExecuteWebhooks: every active webhook in a workspace, with conditions. */ + async listActiveWithConditions(input: { + workspaceId: string + }): Promise { + return (await db.query.webhookModel.findMany({ + where: { + workspaceId: input.workspaceId, + active: true, + }, + with: { + conditions: true, + }, + })) as WebhookWithConditions[] + } } export const webhookService = new WebhookService() diff --git a/packages/business/src/workspace-usage/service.ts b/packages/business/src/workspace-usage/service.ts index 62836e6559..740774a00f 100644 --- a/packages/business/src/workspace-usage/service.ts +++ b/packages/business/src/workspace-usage/service.ts @@ -1,8 +1,21 @@ -import { db } from "@chatbotx.io/database/client" -import { workspaceUsageModel } from "@chatbotx.io/database/schema" +import { count as countFn, db, sql } from "@chatbotx.io/database/client" +import { + contactModel, + inboxModel, + workspaceMemberModel, + workspaceModel, + workspaceUsageModel, +} from "@chatbotx.io/database/schema" import type { WorkspaceUsageModel } from "@chatbotx.io/database/types" import { LiveCounterStore } from "../quota-shared/live-counter-store" +export type ReconcileWorkspaceCounts = { + workspaceIds: string[] + contactsByWorkspace: Map + channelsByWorkspace: Map + membersByWorkspace: Map +} + export type WorkspaceUsageMetric = | "contacts" | "channels" @@ -90,6 +103,86 @@ class WorkspaceUsageService { async invalidate(workspaceId: string): Promise { await this.store.invalidate(workspaceId) } + + /** + * `sync-user-quota.ts` reconcileWorkspaceUsage: the workspace-id list plus + * the three grouped counts (contacts / channels / team members) the + * display-only `WorkspaceUsage` breakdown is re-grounded from. MAC counts + * come from `@chatbotx.io/analytics`'s `macRepository`, which stays called + * from the handler and is merged with this method's result there. + */ + async loadReconcileCounts(): Promise { + const [workspaces, contactCounts, channelCounts, memberCounts] = + await Promise.all([ + db.select({ id: workspaceModel.id }).from(workspaceModel), + db + .select({ workspaceId: contactModel.workspaceId, used: countFn() }) + .from(contactModel) + .groupBy(contactModel.workspaceId), + db + .select({ workspaceId: inboxModel.workspaceId, used: countFn() }) + .from(inboxModel) + .groupBy(inboxModel.workspaceId), + db + .select({ + workspaceId: workspaceMemberModel.workspaceId, + used: countFn(), + }) + .from(workspaceMemberModel) + .groupBy(workspaceMemberModel.workspaceId), + ]) + + return { + workspaceIds: workspaces.map((row) => row.id), + contactsByWorkspace: new Map( + contactCounts.map((row) => [row.workspaceId, row.used]), + ), + channelsByWorkspace: new Map( + channelCounts.map((row) => [row.workspaceId, row.used]), + ), + membersByWorkspace: new Map( + memberCounts.map((row) => [row.workspaceId, row.used]), + ), + } + } + + /** `sync-user-quota.ts` reconcileWorkspaceUsage: upsert the reconciled snapshot. */ + async upsertReconciled(input: { + workspaceId: string + contactsUsed: number + channelsUsed: number + teamMembersUsed: number + macUsed: number + }): Promise { + const { + workspaceId, + contactsUsed, + channelsUsed, + teamMembersUsed, + macUsed, + } = input + await db + .insert(workspaceUsageModel) + .values({ + workspaceId, + contactsUsed, + channelsUsed, + teamMembersUsed, + macUsed, + syncedAt: new Date(), + }) + .onConflictDoUpdate({ + target: workspaceUsageModel.workspaceId, + set: { + contactsUsed, + channelsUsed, + teamMembersUsed, + macUsed, + syncedAt: new Date(), + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + } } export const workspaceUsageService = new WorkspaceUsageService() diff --git a/packages/database/__tests__/contact-export-page-repository.test.ts b/packages/database/__tests__/contact-export-page-repository.test.ts new file mode 100644 index 0000000000..898c47b482 --- /dev/null +++ b/packages/database/__tests__/contact-export-page-repository.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// contactRepository.listForExportPage — the relational `with` literal moved +// here from apps/worker/src/default/handlers/export-contacts.ts so Drizzle +// keeps type inference. These assertions were relocated from +// apps/worker/__tests__/export-contacts-handler.test.ts, which can no longer +// see the query shape now that the handler only passes a boolean flag. +// --------------------------------------------------------------------------- + +const findManyContacts = vi.fn() + +vi.mock("../src/client", () => ({ + db: { + query: { + contactModel: { + findMany: (...args: unknown[]) => findManyContacts(...args), + }, + }, + }, +})) + +const { contactRepository } = await import( + "../src/repositories/contact/repository" +) + +beforeEach(() => { + findManyContacts.mockReset() + findManyContacts.mockResolvedValue([]) +}) + +describe("contactRepository.listForExportPage", () => { + test("passes the caller's where and limit through and keys the page on id asc", async () => { + const where = { workspaceId: "ws-1", id: { gt: "10" } } + + await contactRepository.listForExportPage({ + where, + limit: 2, + includeSourceUserId: false, + }) + + expect(findManyContacts).toHaveBeenCalledWith( + expect.objectContaining({ + where, + limit: 2, + orderBy: { id: "asc" }, + }), + ) + }) + + test("always loads contactCustomFields and tags relations", async () => { + await contactRepository.listForExportPage({ + where: {}, + limit: 2, + includeSourceUserId: false, + }) + + const query = findManyContacts.mock.calls[0][0] as { + with: Record + } + expect(query.with.contactCustomFields).toBe(true) + expect(query.with.tags).toBe(true) + }) + + test("keeps the single-row contactInboxes load when sourceUserId is not selected", async () => { + await contactRepository.listForExportPage({ + where: {}, + limit: 2, + includeSourceUserId: false, + }) + + const query = findManyContacts.mock.calls[0][0] as { + with: { + contactInboxes: { + columns: Record + orderBy: unknown + limit?: number + } + } + } + expect(query.with.contactInboxes.columns).toMatchObject({ + sourceId: true, + sourceUserId: true, + }) + expect(query.with.contactInboxes.orderBy).toEqual({ id: "asc" }) + // The Contact Id column only needs the earliest inbox row. + expect(query.with.contactInboxes.limit).toBe(1) + }) + + test("lifts the contactInboxes limit ONLY when sourceUserId is selected", async () => { + await contactRepository.listForExportPage({ + where: {}, + limit: 2, + includeSourceUserId: true, + }) + + const query = findManyContacts.mock.calls[0][0] as { + with: { contactInboxes: { limit?: number } } + } + // The WhatsApp User ID column must scan every inbox connection, so the + // multi-inbox scan is only paid for when that column is actually selected. + expect(query.with.contactInboxes.limit).toBeUndefined() + }) +}) diff --git a/packages/database/__tests__/mac-partitions-repository.test.ts b/packages/database/__tests__/mac-partitions-repository.test.ts new file mode 100644 index 0000000000..b693e0e4b4 --- /dev/null +++ b/packages/database/__tests__/mac-partitions-repository.test.ts @@ -0,0 +1,132 @@ +import { PgDialect } from "drizzle-orm/pg-core" +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// mac-partitions repository — DDL helpers for the schedule:maintain-mac- +// partitions cron. Mocks only db.execute (real `sql`/`sql.identifier`/`sql.raw` +// from drizzle-orm/pg-core via importOriginal), asserting the rendered SQL +// with `PgDialect().sqlToQuery` the same way broadcast-purge.test.ts does. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), +})) + +vi.mock("../src/client", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + db: { execute: mocks.execute }, + } +}) + +const { + partitionExists, + createContactActiveMonthlyPartition, + createContactActiveHourlyPartition, + addUtcMonths, + formatMonthlyPartitionName, + formatUtcDate, +} = await import("../src/repositories/mac-partitions") + +const dialect = new PgDialect() + +function renderQuery(sqlArg: unknown): { text: string; params: unknown[] } { + const { sql: text, params } = dialect.sqlToQuery(sqlArg as never) + return { text: text.replace(/\s+/g, " ").trim(), params } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("partitionExists", () => { + test("probes pg_class by relname, bound as a param", async () => { + mocks.execute.mockResolvedValue({ rows: [{ exists: true }] }) + + const result = await partitionExists("ContactActiveMonthly_2026") + + expect(result).toBe(true) + const { text, params } = renderQuery(mocks.execute.mock.calls[0]?.[0]) + expect(text).toBe( + 'SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = $1) AS "exists"', + ) + expect(params).toEqual(["ContactActiveMonthly_2026"]) + }) + + test("returns false when the row is absent", async () => { + mocks.execute.mockResolvedValue({ rows: [] }) + await expect(partitionExists("missing")).resolves.toBe(false) + }) +}) + +describe("createContactActiveMonthlyPartition", () => { + test("skips creation when the partition already exists", async () => { + mocks.execute.mockResolvedValueOnce({ rows: [{ exists: true }] }) + + const result = await createContactActiveMonthlyPartition(2026) + + expect(result).toBe(false) + expect(mocks.execute).toHaveBeenCalledTimes(1) + }) + + test("creates the yearly partition with FROM/TO date literals when absent", async () => { + mocks.execute + .mockResolvedValueOnce({ rows: [{ exists: false }] }) + .mockResolvedValueOnce({ rows: [] }) + + const result = await createContactActiveMonthlyPartition(2026) + + expect(result).toBe(true) + const { text } = renderQuery(mocks.execute.mock.calls[1]?.[0]) + expect(text).toBe( + "CREATE TABLE IF NOT EXISTS \"ContactActiveMonthly_2026\" PARTITION OF \"ContactActiveMonthly\" FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')", + ) + }) +}) + +describe("createContactActiveHourlyPartition", () => { + test("skips creation when the monthly partition already exists", async () => { + mocks.execute.mockResolvedValueOnce({ rows: [{ exists: true }] }) + + const result = await createContactActiveHourlyPartition( + new Date(Date.UTC(2026, 5, 1)), + ) + + expect(result).toBe(false) + expect(mocks.execute).toHaveBeenCalledTimes(1) + }) + + test("creates the monthly partition spanning exactly one UTC month", async () => { + mocks.execute + .mockResolvedValueOnce({ rows: [{ exists: false }] }) + .mockResolvedValueOnce({ rows: [] }) + + const result = await createContactActiveHourlyPartition( + new Date(Date.UTC(2026, 5, 1)), + ) + + expect(result).toBe(true) + const { text } = renderQuery(mocks.execute.mock.calls[1]?.[0]) + expect(text).toBe( + "CREATE TABLE IF NOT EXISTS \"ContactActiveHourly_2026_06\" PARTITION OF \"ContactActiveHourly\" FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')", + ) + }) +}) + +describe("date/name helpers", () => { + test("addUtcMonths advances by whole UTC months, normalized to day 1", () => { + const result = addUtcMonths(new Date(Date.UTC(2026, 11, 15)), 2) + expect(result.toISOString()).toBe("2027-02-01T00:00:00.000Z") + }) + + test("formatMonthlyPartitionName zero-pads the month", () => { + expect(formatMonthlyPartitionName(new Date(Date.UTC(2026, 0, 1)))).toBe( + "ContactActiveHourly_2026_01", + ) + }) + + test("formatUtcDate renders YYYY-MM-01", () => { + expect(formatUtcDate(new Date(Date.UTC(2026, 8, 20)))).toBe("2026-09-01") + }) +}) diff --git a/packages/database/__tests__/sequence-dispatch-repository.test.ts b/packages/database/__tests__/sequence-dispatch-repository.test.ts new file mode 100644 index 0000000000..ddf4243fe4 --- /dev/null +++ b/packages/database/__tests__/sequence-dispatch-repository.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// sequenceDispatchRepository — the query/claim/delete surface backing the +// sequence-scheduler worker (dispatch-processor, worker-producer, worker's +// reconcile/cleanup/retention loops, step-executor). Mocks `db` at the module +// boundary (query builder chain), asserting shapes without touching a real +// database or importOriginal-ing the schema module. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + sql: [strings, values], + })), + execute: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findFirstStep: vi.fn(), + update: vi.fn(), +})) + +vi.mock("../src/client", () => ({ + and: mocks.and, + eq: mocks.eq, + sql: mocks.sql, + db: { + execute: mocks.execute, + update: mocks.update, + query: { + sequenceDispatchModel: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + sequenceStepModel: { + findFirst: mocks.findFirstStep, + }, + }, + }, +})) + +vi.mock("../src/schema", () => ({ + sequenceDispatchModel: { + id: "id", + workspaceId: "workspaceId", + status: "status", + }, +})) + +const { sequenceDispatchRepository } = await import( + "../src/repositories/sequence-dispatch/repository" +) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("findWithRelations", () => { + test("scopes by id, status, and workspaceId with sequence/contact/enrollment relations", async () => { + const row = { id: "d-1", status: "pending", workspaceId: "ws-1" } + mocks.findFirst.mockResolvedValue(row) + + const result = await sequenceDispatchRepository.findWithRelations({ + id: "d-1", + status: "pending", + workspaceId: "ws-1", + }) + + expect(result).toEqual(row) + expect(mocks.findFirst).toHaveBeenCalledWith({ + where: { id: "d-1", status: "pending", workspaceId: "ws-1" }, + with: { sequence: true, contact: true, enrollment: true }, + }) + }) + + test("returns null when no row matches", async () => { + mocks.findFirst.mockResolvedValue(undefined) + await expect( + sequenceDispatchRepository.findWithRelations({ + id: "d-missing", + status: "pending", + workspaceId: "ws-1", + }), + ).resolves.toBeNull() + }) +}) + +describe("claim", () => { + test("issues a status='pending' CAS update and returns true when a row was affected", async () => { + const returning = vi.fn(() => Promise.resolve([{ id: "d-1" }])) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + mocks.update.mockReturnValue({ set }) + + const beforeMs = Date.now() + const result = await sequenceDispatchRepository.claim({ + id: "d-1", + workspaceId: "ws-1", + lockOwner: "host-1", + }) + const afterMs = Date.now() + + expect(result).toBe(true) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ status: "running", lockOwner: "host-1" }), + ) + const setArg = set.mock.calls[0][0] as { lockedAt: Date } + expect(setArg.lockedAt).toBeInstanceOf(Date) + expect(setArg.lockedAt.getTime()).toBeGreaterThanOrEqual(beforeMs) + expect(setArg.lockedAt.getTime()).toBeLessThanOrEqual(afterMs) + // The pending-status predicate must be part of the WHERE — never a + // read-then-write. + expect(mocks.eq).toHaveBeenCalledWith("status", "pending") + // The claim's idempotency guard is the full (id, workspaceId, status) + // conjunction — never scope by id alone or drop workspaceId, or one + // tenant's scheduler could claim another tenant's pending dispatch. + expect(mocks.and).toHaveBeenCalledWith( + { eq: ["id", "d-1"] }, + { eq: ["workspaceId", "ws-1"] }, + { eq: ["status", "pending"] }, + ) + }) + + test("returns false when the row was no longer pending (lost the race)", async () => { + const returning = vi.fn(() => Promise.resolve([])) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + mocks.update.mockReturnValue({ set }) + + const result = await sequenceDispatchRepository.claim({ + id: "d-1", + workspaceId: "ws-1", + lockOwner: "host-1", + }) + + expect(result).toBe(false) + }) +}) + +describe("listPendingWorkspaceIds", () => { + test("returns [] without querying when ids is empty", async () => { + const result = await sequenceDispatchRepository.listPendingWorkspaceIds({ + ids: [], + }) + expect(result).toEqual([]) + expect(mocks.findMany).not.toHaveBeenCalled() + }) + + test("scopes to the given ids and status=pending", async () => { + mocks.findMany.mockResolvedValue([{ id: "d-1", workspaceId: "ws-1" }]) + + const result = await sequenceDispatchRepository.listPendingWorkspaceIds({ + ids: ["d-1"], + }) + + expect(result).toEqual([{ id: "d-1", workspaceId: "ws-1" }]) + expect(mocks.findMany).toHaveBeenCalledWith({ + where: { id: { in: ["d-1"] }, status: "pending" }, + columns: { id: true, workspaceId: true }, + }) + }) +}) + +describe("listPendingForReconcile", () => { + test("pages by runAtMs ascending, scoped to status=pending", async () => { + mocks.findMany.mockResolvedValue([]) + + await sequenceDispatchRepository.listPendingForReconcile({ + maxRunAtMs: "1000", + offset: 0, + limit: 1000, + }) + + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: "pending", runAtMs: { lte: "1000" } }, + offset: 0, + limit: 1000, + }), + ) + }) +}) + +describe("listPendingIds", () => { + test("returns [] without querying when ids is empty", async () => { + const result = await sequenceDispatchRepository.listPendingIds({ + ids: [], + }) + expect(result).toEqual([]) + expect(mocks.findMany).not.toHaveBeenCalled() + }) +}) + +describe("deleteTerminalBatch", () => { + test("deletes terminal rows via the CTE joined on workspaceId for partition pruning", async () => { + mocks.execute.mockResolvedValue({ rows: [{ id: "d-1" }, { id: "d-2" }] }) + + const result = await sequenceDispatchRepository.deleteTerminalBatch({ + retentionTtlDays: 30, + batchSize: 1000, + }) + + expect(result).toBe(2) + expect(mocks.execute).toHaveBeenCalledTimes(1) + // The outer sql-tag call carries the "SequenceDispatch"/rows join text as + // string segments and the bound retentionTtlDays/batchSize as the + // template's interpolated values (each call to the mocked `sql` tag is + // one template literal — retentionTtlDays and batchSize are each their + // own interpolation, so it's the LAST call (outermost, executed first by + // JS evaluation order... actually the outer template is evaluated last + // since inner ${} expressions run first). Assert the outer call's text. + const outerCall = mocks.sql.mock.calls.at(-1) as [ + TemplateStringsArray, + unknown[], + ] + const [strings] = outerCall + const renderedText = strings.join("") + expect(renderedText).toContain("WITH rows AS") + expect(renderedText).toContain('"SequenceDispatch"') + expect(renderedText).toContain('sd."workspaceId" = rows."workspaceId"') + }) +}) + +describe("findStepWithFlow", () => { + test("delegates to sequenceStepModel with the flow relation", async () => { + mocks.findFirstStep.mockResolvedValue({ id: "step-1", flow: { id: "f-1" } }) + + const result = await sequenceDispatchRepository.findStepWithFlow({ + id: "step-1", + }) + + expect(result).toEqual({ id: "step-1", flow: { id: "f-1" } }) + expect(mocks.findFirstStep).toHaveBeenCalledWith({ + where: { id: "step-1" }, + with: { flow: true }, + }) + }) +}) diff --git a/packages/database/__tests__/tag-channel-repository.test.ts b/packages/database/__tests__/tag-channel-repository.test.ts new file mode 100644 index 0000000000..147e0548c3 --- /dev/null +++ b/packages/database/__tests__/tag-channel-repository.test.ts @@ -0,0 +1,450 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// --------------------------------------------------------------------------- +// tagChannelRepository — TagChannel / ContactToTagChannel / ContactsToTags +// mutations backing sync-channel-labels.ts and sync-tag.ts. Mocks db at the +// module boundary and asserts onConflict targets / early-return chains that +// were moved verbatim from the original handlers. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ + inArray: [column, values], + })), + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + sql: [strings, values], + })), + insert: vi.fn(), + update: vi.fn(), + deleteFn: vi.fn(), + select: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + createId: vi.fn(), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mocks.createId, +})) + +vi.mock("../src/client", () => ({ + and: mocks.and, + eq: mocks.eq, + inArray: mocks.inArray, + sql: mocks.sql, + db: { + insert: mocks.insert, + update: mocks.update, + delete: mocks.deleteFn, + select: mocks.select, + query: { + tagChannelModel: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + contactToTagChannelModel: { findMany: mocks.findMany }, + contactsToTagsModel: { findMany: mocks.findMany }, + }, + }, +})) + +vi.mock("../src/schema", () => ({ + tagModel: { id: "id", workspaceId: "workspaceId", name: "name" }, + tagChannelModel: { + id: "id", + tagId: "tagId", + channelType: "channelType", + integrationId: "integrationId", + externalLabelId: "externalLabelId", + }, + contactsToTagsModel: { contactId: "contactId", tagId: "tagId" }, + contactToTagChannelModel: { + tagId: "tagId", + tagChannelId: "tagChannelId", + contactInboxId: "contactInboxId", + }, + contactInboxModel: { contactId: "contactId", sourceId: "sourceId" }, +})) + +const { tagChannelRepository } = await import( + "../src/repositories/tag-channel/repository" +) + +function insertChain(finalResult: unknown[] = []) { + const builder = { + values: vi.fn(() => builder), + onConflictDoNothing: vi.fn(() => builder), + onConflictDoUpdate: vi.fn(() => builder), + returning: vi.fn(() => Promise.resolve(finalResult)), + } + return builder +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.createId.mockReset() + let callCount = 0 + mocks.createId.mockImplementation(() => `generated-id-${++callCount}`) +}) + +describe("upsertLabelMapping", () => { + test("exits early when the tag upsert returns no row", async () => { + mocks.insert.mockReturnValueOnce(insertChain([])) + + await tagChannelRepository.upsertLabelMapping({ + workspaceId: "ws-1", + channelType: "messenger", + integrationId: "int-1", + label: { externalLabelId: "ext-1", name: "VIP" }, + contactInbox: { id: "ci-1", contactId: "c-1" }, + }) + + expect(mocks.insert).toHaveBeenCalledTimes(1) + expect(mocks.createId).toHaveBeenCalledTimes(1) + }) + + test("exits early when the tagChannel upsert returns no row", async () => { + mocks.insert + .mockReturnValueOnce(insertChain([{ id: "tag-1" }])) + .mockReturnValueOnce(insertChain([])) + + await tagChannelRepository.upsertLabelMapping({ + workspaceId: "ws-1", + channelType: "messenger", + integrationId: "int-1", + label: { externalLabelId: "ext-1", name: "VIP" }, + contactInbox: { id: "ci-1", contactId: "c-1" }, + }) + + expect(mocks.insert).toHaveBeenCalledTimes(2) + expect(mocks.createId).toHaveBeenCalledTimes(2) + }) + + test("links the contact-inbox to the tag and tagChannel when both upserts succeed", async () => { + const tagChain = insertChain([{ id: "tag-1" }]) + const tagChannelChain = insertChain([{ id: "tc-1" }]) + const contactsToTagsChain = insertChain([]) + const contactToTagChannelChain = insertChain([]) + mocks.insert + .mockReturnValueOnce(tagChain) + .mockReturnValueOnce(tagChannelChain) + .mockReturnValueOnce(contactsToTagsChain) + .mockReturnValueOnce(contactToTagChannelChain) + + await tagChannelRepository.upsertLabelMapping({ + workspaceId: "ws-1", + channelType: "messenger", + integrationId: "int-1", + label: { externalLabelId: "ext-1", name: "VIP" }, + contactInbox: { id: "ci-1", contactId: "c-1" }, + }) + + // Insert ordering + target tables: tag -> tagChannel -> contactsToTags -> contactToTagChannel. + expect(mocks.insert).toHaveBeenCalledTimes(4) + expect(mocks.insert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + workspaceId: "workspaceId", + name: "name", + }), + ) + expect(mocks.insert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + tagId: "tagId", + channelType: "channelType", + integrationId: "integrationId", + }), + ) + expect(mocks.insert).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ contactId: "contactId", tagId: "tagId" }), + ) + expect(mocks.insert).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + tagId: "tagId", + tagChannelId: "tagChannelId", + contactInboxId: "contactInboxId", + }), + ) + + // Both link inserts use onConflictDoNothing — never onConflictDoUpdate. + expect(contactsToTagsChain.onConflictDoNothing).toHaveBeenCalledTimes(1) + expect(contactToTagChannelChain.onConflictDoNothing).toHaveBeenCalledTimes( + 1, + ) + + // createId is called once for the tag row and once for the tagChannel row. + expect(mocks.createId).toHaveBeenCalledTimes(2) + + expect(tagChain.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: "generated-id-1", + name: "VIP", + workspaceId: "ws-1", + }), + ) + expect(tagChannelChain.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: "generated-id-2", + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "messenger", + integrationId: "int-1", + externalLabelId: "ext-1", + }), + ) + expect(contactsToTagsChain.values).toHaveBeenCalledWith({ + contactId: "c-1", + tagId: "tag-1", + }) + expect(contactToTagChannelChain.values).toHaveBeenCalledWith({ + tagId: "tag-1", + tagChannelId: "tc-1", + contactInboxId: "ci-1", + }) + }) +}) + +describe("insertIfAbsent", () => { + test("targets the (tagId, channelType, integrationId) conflict key", async () => { + const chain = insertChain([]) + mocks.insert.mockReturnValue(chain) + + await tagChannelRepository.insertIfAbsent({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "zalo", + integrationId: "int-1", + externalLabelId: "VIP", + }) + + expect(chain.onConflictDoNothing).toHaveBeenCalledWith({ + target: ["tagId", "channelType", "integrationId"], + }) + }) +}) + +describe("insertOrFetch", () => { + test("returns the inserted row when the insert wins", async () => { + mocks.insert.mockReturnValueOnce(insertChain([{ id: "tc-1" }])) + + const result = await tagChannelRepository.insertOrFetch({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "messenger", + integrationId: "int-1", + externalLabelId: "ext-1", + }) + + expect(result).toEqual({ id: "tc-1" }) + expect(mocks.findFirst).not.toHaveBeenCalled() + }) + + test("falls back to a refetch when the insert conflicts", async () => { + mocks.insert.mockReturnValueOnce(insertChain([])) + mocks.findFirst.mockResolvedValue({ id: "tc-existing" }) + + const result = await tagChannelRepository.insertOrFetch({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "messenger", + integrationId: "int-1", + externalLabelId: "ext-1", + }) + + expect(result).toEqual({ id: "tc-existing" }) + expect(mocks.findFirst).toHaveBeenCalledTimes(1) + }) +}) + +describe("deleteLinksForChannel / deleteContactTagsForContacts", () => { + test("no-ops without querying when the id list is empty", async () => { + await tagChannelRepository.deleteLinksForChannel({ + tagChannelId: "tc-1", + contactInboxIds: [], + }) + await tagChannelRepository.deleteContactTagsForContacts({ + tagId: "tag-1", + contactIds: [], + }) + + expect(mocks.deleteFn).not.toHaveBeenCalled() + }) + + test("deletes scoped to the given ids when non-empty", async () => { + const chain = { where: vi.fn(() => Promise.resolve(undefined)) } + mocks.deleteFn.mockReturnValue(chain) + + await tagChannelRepository.deleteLinksForChannel({ + tagChannelId: "tc-1", + contactInboxIds: ["ci-1", "ci-2"], + }) + + expect(mocks.deleteFn).toHaveBeenCalled() + expect(mocks.inArray).toHaveBeenCalledWith("contactInboxId", [ + "ci-1", + "ci-2", + ]) + }) +}) + +describe("listContactInboxIdsForChannelPage", () => { + test("pages by contactInboxId ascending, keyed off tagChannelId", async () => { + mocks.findMany.mockResolvedValue([{ contactInboxId: "ci-1" }]) + + const result = await tagChannelRepository.listContactInboxIdsForChannelPage( + { tagChannelId: "tc-1", limit: 500 }, + ) + + expect(result).toEqual([{ contactInboxId: "ci-1" }]) + expect(mocks.findMany).toHaveBeenCalledWith({ + where: { tagChannelId: { in: ["tc-1"] } }, + orderBy: { contactInboxId: "asc" }, + limit: 500, + columns: { contactInboxId: true }, + }) + }) + + test("adds the afterContactInboxId gt-filter when a cursor is passed", async () => { + mocks.findMany.mockResolvedValue([]) + + await tagChannelRepository.listContactInboxIdsForChannelPage({ + tagChannelId: "tc-1", + afterContactInboxId: "ci-5", + limit: 500, + }) + + expect(mocks.findMany).toHaveBeenCalledWith({ + where: { + tagChannelId: { in: ["tc-1"] }, + contactInboxId: { gt: "ci-5" }, + }, + orderBy: { contactInboxId: "asc" }, + limit: 500, + columns: { contactInboxId: true }, + }) + }) +}) + +describe("deleteById", () => { + test("deletes the tagChannel by its own id", async () => { + const chain = { where: vi.fn(() => Promise.resolve(undefined)) } + mocks.deleteFn.mockReturnValue(chain) + + await tagChannelRepository.deleteById({ id: "tc-1" }) + + expect(mocks.deleteFn).toHaveBeenCalled() + expect(mocks.eq).toHaveBeenCalledWith("id", "tc-1") + }) +}) + +describe("findByTagAndIntegration", () => { + test("scopes the lookup by tagId, workspaceId, channelType, and integrationId", async () => { + mocks.findFirst.mockResolvedValue({ id: "tc-1" }) + + const result = await tagChannelRepository.findByTagAndIntegration({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "zalo", + integrationId: "int-1", + }) + + expect(result).toEqual({ id: "tc-1" }) + expect(mocks.findFirst).toHaveBeenCalledWith({ + where: { + tagId: "tag-1", + workspaceId: "ws-1", + channelType: "zalo", + integrationId: "int-1", + }, + }) + }) +}) + +describe("updateExternalLabelId", () => { + test("updates only the row matching the given id", async () => { + const where = vi.fn(() => Promise.resolve(undefined)) + const set = vi.fn(() => ({ where })) + mocks.update.mockReturnValue({ set }) + + await tagChannelRepository.updateExternalLabelId({ + id: "tc-1", + externalLabelId: "new-label", + }) + + expect(set).toHaveBeenCalledWith({ externalLabelId: "new-label" }) + expect(mocks.eq).toHaveBeenCalledWith("id", "tc-1") + }) +}) + +describe("upsertByTagAndIntegration", () => { + test("targets the (tagId, channelType, integrationId) conflict key and returns the row", async () => { + const chain = insertChain([{ id: "tc-1", externalLabelId: "ext-1" }]) + mocks.insert.mockReturnValue(chain) + + const result = await tagChannelRepository.upsertByTagAndIntegration({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "zalo", + integrationId: "int-1", + externalLabelId: "ext-1", + }) + + expect(result).toEqual({ id: "tc-1", externalLabelId: "ext-1" }) + expect(chain.onConflictDoUpdate).toHaveBeenCalledWith({ + target: ["tagId", "channelType", "integrationId"], + set: { externalLabelId: "ext-1" }, + }) + }) +}) + +describe("listByTag", () => { + test("scopes by tagId and workspaceId with no optional filters", async () => { + mocks.findMany.mockResolvedValue([]) + + await tagChannelRepository.listByTag({ + workspaceId: "ws-1", + tagId: "tag-1", + }) + + expect(mocks.findMany).toHaveBeenCalledWith({ + where: { tagId: "tag-1", workspaceId: "ws-1" }, + columns: { + id: true, + channelType: true, + integrationId: true, + externalLabelId: true, + }, + }) + }) + + test("adds channelType/integrationId filters only when provided", async () => { + mocks.findMany.mockResolvedValue([]) + + await tagChannelRepository.listByTag({ + workspaceId: "ws-1", + tagId: "tag-1", + channelType: "messenger", + integrationId: "int-1", + }) + + expect(mocks.findMany).toHaveBeenCalledWith({ + where: { + tagId: "tag-1", + workspaceId: "ws-1", + channelType: "messenger", + integrationId: "int-1", + }, + columns: { + id: true, + channelType: true, + integrationId: true, + externalLabelId: true, + }, + }) + }) +}) diff --git a/packages/database/src/repositories/contact-inbox/repository.ts b/packages/database/src/repositories/contact-inbox/repository.ts index 5a6bf9e5c4..d7fbeb2b08 100644 --- a/packages/database/src/repositories/contact-inbox/repository.ts +++ b/packages/database/src/repositories/contact-inbox/repository.ts @@ -557,4 +557,57 @@ export const contactInboxRepository = { }, }) }, + + /** + * `sync-channel-labels.ts` scan page: every contact inbox on one inbox, + * keyset-paginated by id. Returns full rows (the handler reads `sourceId`, + * `contactId`, etc. off the whole model when scanning). + */ + async listByInboxPage( + input: { inboxId: string; afterId?: string; limit: number }, + tx: DatabaseClient = db, + ) { + return await tx.query.contactInboxModel.findMany({ + where: { + inboxId: input.inboxId, + ...(input.afterId ? { id: { gt: input.afterId } } : {}), + }, + orderBy: { id: "asc" }, + limit: input.limit, + }) + }, + + /** + * `sync-tag.ts` delete path: resolves the distinct contact ids owning a + * page of contact-inbox ids, so the caller can prune `ContactsToTags` rows + * for exactly those contacts. + */ + async listContactIdsByIds( + input: { ids: string[] }, + tx: DatabaseClient = db, + ): Promise<{ contactId: string }[]> { + if (input.ids.length === 0) { + return [] + } + return await tx.query.contactInboxModel.findMany({ + where: { id: { in: input.ids } }, + columns: { contactId: true }, + }) + }, + + /** + * `sync-tag.ts` attach path: every contact-inbox for a contact, with no + * `workspaceId` filter (the caller has only a `contactId` in scope at this + * point). Distinct from `contactInboxService.listByContactId`, which + * requires `workspaceId` and is cached — this is an uncached, unscoped + * full-row read. + */ + async listByContactId( + input: { contactId: string }, + tx: DatabaseClient = db, + ): Promise { + return await tx.query.contactInboxModel.findMany({ + where: { contactId: input.contactId }, + }) + }, } diff --git a/packages/database/src/repositories/contact/index.ts b/packages/database/src/repositories/contact/index.ts index 3737d1856a..8bd4681553 100644 --- a/packages/database/src/repositories/contact/index.ts +++ b/packages/database/src/repositories/contact/index.ts @@ -3,4 +3,5 @@ export { DEFAULT_CONTACT_ORDER_BY, resolveContactOrderBy, } from "./list-where" +export type { ContactExportPageRow } from "./repository" export { contactRepository } from "./repository" diff --git a/packages/database/src/repositories/contact/repository.ts b/packages/database/src/repositories/contact/repository.ts index 3c2f738fba..f90256fdbd 100644 --- a/packages/database/src/repositories/contact/repository.ts +++ b/packages/database/src/repositories/contact/repository.ts @@ -180,4 +180,42 @@ export const contactRepository = { ) .returning({ id: contactModel.id }) }, + /** + * Contact-export keyset page (`apps/worker/src/default/handlers/export- + * contacts.ts`). The `with` literal lives here so Drizzle keeps type + * inference for the relational query; the handler keeps `chunkById` and its + * own `fetchContactPage` wrapper around this call. + */ + async listForExportPage( + input: { + where: Record + limit: number + includeSourceUserId: boolean + }, + tx: DatabaseClient = db, + ) { + return await tx.query.contactModel.findMany({ + where: input.where, + with: { + contactCustomFields: true, + tags: true, + // The Contact Id column only needs the earliest row's sourceId. The + // WhatsApp User ID column must scan every inbox connection for the + // row that actually carries a sourceUserId, so the earliest-row + // limit is lifted ONLY when that column is selected — ordinary + // exports keep the single-row load. + contactInboxes: { + columns: { sourceId: true, sourceUserId: true }, + orderBy: { id: "asc" }, + ...(input.includeSourceUserId ? {} : { limit: 1 }), + }, + }, + limit: input.limit, + orderBy: { id: "asc" }, + }) + }, } + +export type ContactExportPageRow = Awaited< + ReturnType +>[number] diff --git a/packages/database/src/repositories/file/repository.ts b/packages/database/src/repositories/file/repository.ts index 2d7cc4e945..58b07eb46f 100644 --- a/packages/database/src/repositories/file/repository.ts +++ b/packages/database/src/repositories/file/repository.ts @@ -3,6 +3,30 @@ import { fileModel } from "../../schema" import type { FileModel } from "../../types" export const fileRepository = { + /** + * Updates a `File` row scoped to `(id, workspaceId)` so a caller can never + * write another workspace's upload row. Used by the contact-export job to + * persist progress/status on its own export file. + */ + async updateForWorkspace( + input: { + id: string + workspaceId: string + values: Partial + }, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(fileModel) + .set(input.values) + .where( + and( + eq(fileModel.id, input.id), + eq(fileModel.workspaceId, input.workspaceId), + ), + ) + }, + /** * Ownership proof for a presigned-upload `File` row — scoped to * `(id, workspaceId)` so a caller can never probe another workspace's diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index 9d73243577..6a8dbb39d4 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -30,6 +30,7 @@ export * from "./integration-instagram" export * from "./integration-lookup" export * from "./integration-messenger" export * from "./integration-whatsapp" +export * from "./mac-partitions" export * from "./media-library-file" export * from "./media-library-folder" export * from "./message" @@ -41,6 +42,8 @@ export * from "./product" export * from "./product-category" export * from "./reflink" export * from "./sequence" +export * from "./sequence-dispatch" +export * from "./tag-channel" export * from "./template-selectable-resource" export * from "./trigger" export * from "./user-persistent-menu" diff --git a/packages/database/src/repositories/integration-messenger/repository.ts b/packages/database/src/repositories/integration-messenger/repository.ts index 2d6b0f315e..de93016dfa 100644 --- a/packages/database/src/repositories/integration-messenger/repository.ts +++ b/packages/database/src/repositories/integration-messenger/repository.ts @@ -376,4 +376,25 @@ export const integrationMessengerRepository = { where: { pageId: props.pageId }, }) }, + + /** `sync-tag.ts` attach path: resolve the Messenger integration owning an inbox. */ + async findByInboxId( + input: { inboxId: string }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.integrationMessengerModel.findFirst({ + where: { inboxId: input.inboxId }, + }) + return row ?? null + }, + + /** `sync-tag.ts` create path: every Messenger integration in the workspace, full rows. */ + async listByWorkspace( + input: { workspaceId: string }, + tx: DatabaseClient = db, + ): Promise { + return await tx.query.integrationMessengerModel.findMany({ + where: { workspaceId: input.workspaceId }, + }) + }, } diff --git a/packages/database/src/repositories/mac-partitions.ts b/packages/database/src/repositories/mac-partitions.ts new file mode 100644 index 0000000000..82f8f72d71 --- /dev/null +++ b/packages/database/src/repositories/mac-partitions.ts @@ -0,0 +1,69 @@ +import { db, sql } from "../client" + +/** + * DDL helpers for `maintain-mac-partitions.ts` (the `schedule:maintain-mac- + * partitions` cron). Moved verbatim from the handler — this is raw `CREATE + * TABLE ... PARTITION OF` DDL with `sql.raw` date literals; do not "improve" + * the parameterisation or add a transaction/advisory lock that isn't here + * today, behaviour must stay identical. + */ + +export async function partitionExists(name: string): Promise { + const result = await db.execute<{ exists: boolean }>(sql` + SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = ${name}) AS "exists" + `) + return result.rows[0]?.exists ?? false +} + +/** Yearly partition for `ContactActiveMonthly`. Returns whether it was created. */ +export async function createContactActiveMonthlyPartition( + year: number, +): Promise { + const name = `ContactActiveMonthly_${year}` + if (await partitionExists(name)) { + return false + } + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS ${sql.identifier(name)} + PARTITION OF "ContactActiveMonthly" + FOR VALUES FROM (${sql.raw(`'${year}-01-01'`)}) TO (${sql.raw(`'${year + 1}-01-01'`)}) + `) + return true +} + +export function addUtcMonths(date: Date, months: number): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1), + ) +} + +export function formatMonthlyPartitionName(date: Date): string { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, "0") + return `ContactActiveHourly_${year}_${month}` +} + +export function formatUtcDate(date: Date): string { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, "0") + return `${year}-${month}-01` +} + +/** Monthly partition for `ContactActiveHourly`. Returns whether it was created. */ +export async function createContactActiveHourlyPartition( + monthStart: Date, +): Promise { + const name = formatMonthlyPartitionName(monthStart) + if (await partitionExists(name)) { + return false + } + + const nextMonth = addUtcMonths(monthStart, 1) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS ${sql.identifier(name)} + PARTITION OF "ContactActiveHourly" + FOR VALUES FROM (${sql.raw(`'${formatUtcDate(monthStart)}'`)}) TO (${sql.raw(`'${formatUtcDate(nextMonth)}'`)}) + `) + return true +} diff --git a/packages/database/src/repositories/sequence-dispatch/index.ts b/packages/database/src/repositories/sequence-dispatch/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/sequence-dispatch/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/sequence-dispatch/repository.ts b/packages/database/src/repositories/sequence-dispatch/repository.ts new file mode 100644 index 0000000000..b2a5995b7f --- /dev/null +++ b/packages/database/src/repositories/sequence-dispatch/repository.ts @@ -0,0 +1,181 @@ +import { and, type DatabaseClient, db, eq, sql } from "../../client" +import { sequenceDispatchModel } from "../../schema" + +type DispatchQueryResult = Awaited< + ReturnType< + typeof db.query.sequenceDispatchModel.findFirst<{ + with: { sequence: true; contact: true; enrollment: true } + }> + > +> + +export type DispatchWithRelations = NonNullable + +type StepQueryResult = Awaited< + ReturnType< + typeof db.query.sequenceStepModel.findFirst<{ with: { flow: true } }> + > +> + +export type SequenceStepWithFlow = NonNullable + +export const sequenceDispatchRepository = { + /** `dispatch-processor.service.ts` fetchDispatch. */ + async findWithRelations( + input: { id: string; status: string; workspaceId: string }, + tx: DatabaseClient = db, + ): Promise { + const dispatch = await tx.query.sequenceDispatchModel.findFirst({ + where: { + id: input.id, + status: input.status, + workspaceId: input.workspaceId, + }, + with: { + sequence: true, + contact: true, + enrollment: true, + }, + }) + return dispatch ?? null + }, + + /** + * `dispatch-processor.service.ts` lockDispatch: the `status = 'pending'` + * predicate plus the affected-row check is the idempotency guard against a + * concurrent worker claiming the same dispatch — never turn this into a + * read-then-write. `lockOwner` is read from `process.env.HOSTNAME` by the + * caller so this repository stays env-free. + */ + async claim( + input: { id: string; workspaceId: string; lockOwner: string }, + tx: DatabaseClient = db, + ): Promise { + const updated = await tx + .update(sequenceDispatchModel) + .set({ + status: "running", + lockedAt: new Date(), + lockOwner: input.lockOwner, + updatedAt: new Date(), + }) + .where( + and( + eq(sequenceDispatchModel.id, input.id), + eq(sequenceDispatchModel.workspaceId, input.workspaceId), + eq(sequenceDispatchModel.status, "pending"), + ), + ) + .returning({ id: sequenceDispatchModel.id }) + + return updated.length > 0 + }, + + /** `worker-producer.ts` publishDispatches. */ + async listPendingWorkspaceIds( + input: { ids: string[] }, + tx: DatabaseClient = db, + ): Promise<{ id: string; workspaceId: string }[]> { + if (input.ids.length === 0) { + return [] + } + return await tx.query.sequenceDispatchModel.findMany({ + where: { + id: { in: input.ids }, + status: "pending", + }, + columns: { id: true, workspaceId: true }, + }) + }, + + /** + * `worker.ts` reconcile: paginated pending-dispatch scan for the bootstrap + * window. `status=pending` intentionally prunes this scan to + * `SequenceDispatch_pending` — this predicate documents a partial-index + * dependency and must not be relaxed or reordered. + */ + async listPendingForReconcile( + input: { maxRunAtMs: string; offset: number; limit: number }, + tx: DatabaseClient = db, + ): Promise< + { + id: string + bucket: number + runAtMs: string + workspaceId: string + contactId: string + }[] + > { + return await tx.query.sequenceDispatchModel.findMany({ + // status=pending intentionally prunes this scan to SequenceDispatch_pending. + where: { + status: "pending", + runAtMs: { lte: input.maxRunAtMs }, + }, + columns: { + id: true, + bucket: true, + runAtMs: true, + workspaceId: true, + contactId: true, + }, + orderBy: (d, { asc }) => [asc(d.runAtMs)], + offset: input.offset, + limit: input.limit, + }) + }, + + /** `worker.ts` cleanupOrphans: which candidate ids are still pending. */ + async listPendingIds( + input: { ids: string[] }, + tx: DatabaseClient = db, + ): Promise<{ id: string }[]> { + if (input.ids.length === 0) { + return [] + } + return await tx.query.sequenceDispatchModel.findMany({ + where: { + id: { in: input.ids }, + status: "pending", + }, + columns: { id: true }, + }) + }, + + /** + * `worker.ts` deleteTerminalDispatches: the `sd."workspaceId" = + * rows."workspaceId"` join predicate in the CTE is the partition-pruning + * key — move verbatim, do not simplify to a plain `id IN (...)` delete. + */ + async deleteTerminalBatch( + input: { retentionTtlDays: number; batchSize: number }, + tx: DatabaseClient = db, + ): Promise { + const result = await tx.execute<{ id: string }>(sql` + WITH rows AS ( + SELECT "id", "workspaceId" + FROM "SequenceDispatch" + WHERE "status" IN ('completed', 'failed', 'canceled') + AND "updatedAt" < NOW() - (${input.retentionTtlDays} * INTERVAL '1 day') + LIMIT ${input.batchSize} + ) + DELETE FROM "SequenceDispatch" sd + USING rows + WHERE sd."id" = rows."id" + AND sd."workspaceId" = rows."workspaceId" + RETURNING sd."id" + `) + return result.rows.length + }, + + /** `step-executor.service.ts` fetchStep. */ + async findStepWithFlow( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + return await tx.query.sequenceStepModel.findFirst({ + where: { id: input.id }, + with: { flow: true }, + }) + }, +} diff --git a/packages/database/src/repositories/tag-channel/index.ts b/packages/database/src/repositories/tag-channel/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/tag-channel/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/tag-channel/repository.ts b/packages/database/src/repositories/tag-channel/repository.ts new file mode 100644 index 0000000000..ab6d76fb33 --- /dev/null +++ b/packages/database/src/repositories/tag-channel/repository.ts @@ -0,0 +1,405 @@ +import { createId } from "@chatbotx.io/utils" +import { and, type DatabaseClient, db, eq, inArray, sql } from "../../client" +import { + contactInboxModel, + contactsToTagsModel, + contactToTagChannelModel, + tagChannelModel, + tagModel, +} from "../../schema" +import type { TagChannelModel } from "../../types" + +// A function, not a module-scope constant: referencing `tagChannelModel`'s +// columns at import time breaks any test that partially mocks +// `@chatbotx.io/database/schema` without `tagChannelModel`, even when that +// test never touches tag-channel code — it only imports the repositories +// barrel. Computing this lazily, inside each call, avoids that. +const tagChannelConflictTarget = () => [ + tagChannelModel.tagId, + tagChannelModel.channelType, + tagChannelModel.integrationId, +] + +export type ContactTagChannelRow = { + tagChannelId: string + contactInboxId: string + channelType: string + integrationId: string + externalLabelId: string + sourceId: string +} + +/** + * Consolidated data-access layer for `TagChannel` / `ContactToTagChannel` / + * `ContactsToTags` mutations used by the `sync-channel-labels` and `sync-tag` + * worker handlers. Every method below is a verbatim move of the query body + * that used to live inline in those handlers. + */ +export const tagChannelRepository = { + /** + * Inbound-scan upsert path (`sync-channel-labels.ts`): creates or renames + * the workspace `Tag`, its `TagChannel` mapping, and links the scanned + * contact inbox to both. Each step exits early when its own insert/upsert + * fails to return a row (mirrors the original handler's early-return + * chain). + */ + async upsertLabelMapping( + input: { + workspaceId: string + channelType: string + integrationId: string + label: { externalLabelId: string; name: string } + contactInbox: { id: string; contactId: string } + }, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, channelType, integrationId, label, contactInbox } = + input + + const [tag] = await tx + .insert(tagModel) + .values({ id: createId(), name: label.name, workspaceId }) + .onConflictDoUpdate({ + target: [tagModel.workspaceId, tagModel.name], + targetWhere: sql`"deletedAt" IS NULL`, + set: { name: sql`EXCLUDED.name` }, + }) + .returning({ id: tagModel.id }) + if (!tag) { + return + } + + const [tagChannel] = await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId, + tagId: tag.id, + channelType, + integrationId, + externalLabelId: label.externalLabelId, + }) + .onConflictDoUpdate({ + target: tagChannelConflictTarget(), + set: { externalLabelId: sql`EXCLUDED."externalLabelId"` }, + }) + .returning({ id: tagChannelModel.id }) + if (!tagChannel) { + return + } + + await tx + .insert(contactsToTagsModel) + .values({ contactId: contactInbox.contactId, tagId: tag.id }) + .onConflictDoNothing() + await tx + .insert(contactToTagChannelModel) + .values({ + tagId: tag.id, + tagChannelId: tagChannel.id, + contactInboxId: contactInbox.id, + }) + .onConflictDoNothing() + }, + + /** `sync-tag(create)` Zalo path: name-based mapping, no API call. */ + async insertIfAbsent( + input: { + workspaceId: string + tagId: string + channelType: string + integrationId: string + externalLabelId: string + }, + tx: DatabaseClient = db, + ): Promise { + await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + tagId: input.tagId, + channelType: input.channelType, + integrationId: input.integrationId, + externalLabelId: input.externalLabelId, + }) + .onConflictDoNothing({ target: tagChannelConflictTarget() }) + }, + + async findByTagAndIntegration( + input: { + workspaceId: string + tagId: string + channelType: string + integrationId: string + }, + tx: DatabaseClient = db, + ): Promise { + return await tx.query.tagChannelModel.findFirst({ + where: { + tagId: input.tagId, + workspaceId: input.workspaceId, + channelType: input.channelType, + integrationId: input.integrationId, + }, + }) + }, + + async updateExternalLabelId( + input: { id: string; externalLabelId: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .update(tagChannelModel) + .set({ externalLabelId: input.externalLabelId }) + .where(eq(tagChannelModel.id, input.id)) + }, + + /** + * `sync-tag(attach)` messenger path: insert-then-refetch fallback so a + * concurrent job that already inserted the mapping still resolves the + * winning row. + */ + async insertOrFetch( + input: { + workspaceId: string + tagId: string + channelType: string + integrationId: string + externalLabelId: string + }, + tx: DatabaseClient = db, + ): Promise { + const inserted = await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + tagId: input.tagId, + channelType: input.channelType, + integrationId: input.integrationId, + externalLabelId: input.externalLabelId, + }) + .onConflictDoNothing({ target: tagChannelConflictTarget() }) + .returning() + if (inserted[0]) { + return inserted[0] + } + return await tx.query.tagChannelModel.findFirst({ + where: { + tagId: input.tagId, + workspaceId: input.workspaceId, + channelType: input.channelType, + integrationId: input.integrationId, + }, + }) + }, + + /** `sync-tag(attach)` Zalo path: upsert keyed by the tag's own name. */ + async upsertByTagAndIntegration( + input: { + workspaceId: string + tagId: string + channelType: string + integrationId: string + externalLabelId: string + }, + tx: DatabaseClient = db, + ): Promise { + const [tagChannel] = await tx + .insert(tagChannelModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + tagId: input.tagId, + channelType: input.channelType, + integrationId: input.integrationId, + externalLabelId: input.externalLabelId, + }) + .onConflictDoUpdate({ + target: tagChannelConflictTarget(), + set: { externalLabelId: input.externalLabelId }, + }) + .returning() + return tagChannel + }, + + async linkContactInbox( + input: { tagId: string; tagChannelId: string; contactInboxId: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .insert(contactToTagChannelModel) + .values({ + tagId: input.tagId, + tagChannelId: input.tagChannelId, + contactInboxId: input.contactInboxId, + }) + .onConflictDoNothing() + }, + + async unlinkContactInbox( + input: { tagChannelId: string; contactInboxId: string }, + tx: DatabaseClient = db, + ): Promise { + await tx + .delete(contactToTagChannelModel) + .where( + and( + eq(contactToTagChannelModel.tagChannelId, input.tagChannelId), + eq(contactToTagChannelModel.contactInboxId, input.contactInboxId), + ), + ) + }, + + /** + * `sync-tag(detach)`: the 3-table join resolving every channel this + * contact's tag is currently mapped onto, so the caller can unassign on + * each channel before deleting the local link. + */ + async listContactTagChannelRows( + input: { tagId: string; contactId: string }, + tx: DatabaseClient = db, + ): Promise { + return await tx + .select({ + tagChannelId: contactToTagChannelModel.tagChannelId, + contactInboxId: contactToTagChannelModel.contactInboxId, + channelType: tagChannelModel.channelType, + integrationId: tagChannelModel.integrationId, + externalLabelId: tagChannelModel.externalLabelId, + sourceId: contactInboxModel.sourceId, + }) + .from(contactToTagChannelModel) + .innerJoin( + tagChannelModel, + eq(contactToTagChannelModel.tagChannelId, tagChannelModel.id), + ) + .innerJoin( + contactInboxModel, + eq(contactToTagChannelModel.contactInboxId, contactInboxModel.id), + ) + .where( + and( + eq(contactToTagChannelModel.tagId, input.tagId), + eq(contactInboxModel.contactId, input.contactId), + ), + ) + }, + + async listByTag( + input: { + workspaceId: string + tagId: string + channelType?: string + integrationId?: string + }, + tx: DatabaseClient = db, + ): Promise< + Pick< + TagChannelModel, + "id" | "channelType" | "integrationId" | "externalLabelId" + >[] + > { + return await tx.query.tagChannelModel.findMany({ + where: { + tagId: input.tagId, + workspaceId: input.workspaceId, + ...(input.channelType ? { channelType: input.channelType } : {}), + ...(input.integrationId ? { integrationId: input.integrationId } : {}), + }, + columns: { + id: true, + channelType: true, + integrationId: true, + externalLabelId: true, + }, + }) + }, + + async deleteById( + input: { id: string }, + tx: DatabaseClient = db, + ): Promise { + await tx.delete(tagChannelModel).where(eq(tagChannelModel.id, input.id)) + }, + + /** `sync-tag(delete)` per-channel page: id-paged by `contactInboxId`. */ + async listContactInboxIdsForChannelPage( + input: { + tagChannelId: string + afterContactInboxId?: string + limit: number + }, + tx: DatabaseClient = db, + ): Promise<{ contactInboxId: string }[]> { + const rows = await tx.query.contactToTagChannelModel.findMany({ + where: { + tagChannelId: { in: [input.tagChannelId] }, + ...(input.afterContactInboxId + ? { contactInboxId: { gt: input.afterContactInboxId } } + : {}), + }, + orderBy: { contactInboxId: "asc" }, + limit: input.limit, + columns: { contactInboxId: true }, + }) + return rows + }, + + async deleteLinksForChannel( + input: { tagChannelId: string; contactInboxIds: string[] }, + tx: DatabaseClient = db, + ): Promise { + if (input.contactInboxIds.length === 0) { + return + } + await tx + .delete(contactToTagChannelModel) + .where( + and( + eq(contactToTagChannelModel.tagChannelId, input.tagChannelId), + inArray( + contactToTagChannelModel.contactInboxId, + input.contactInboxIds, + ), + ), + ) + }, + + async deleteContactTagsForContacts( + input: { tagId: string; contactIds: string[] }, + tx: DatabaseClient = db, + ): Promise { + if (input.contactIds.length === 0) { + return + } + await tx + .delete(contactsToTagsModel) + .where( + and( + eq(contactsToTagsModel.tagId, input.tagId), + inArray(contactsToTagsModel.contactId, input.contactIds), + ), + ) + }, + + /** `sync-tag(delete)` catch-all page: id-paged by `contactId`. */ + async listTaggedContactIdsPage( + input: { tagId: string; afterContactId?: string; limit: number }, + tx: DatabaseClient = db, + ): Promise<{ contactId: string }[]> { + const rows = await tx.query.contactsToTagsModel.findMany({ + where: { + tagId: input.tagId, + ...(input.afterContactId + ? { contactId: { gt: input.afterContactId } } + : {}), + }, + orderBy: { contactId: "asc" }, + limit: input.limit, + columns: { contactId: true }, + }) + return rows + }, +}