From 8010e006dffff38260623a91829673b1b8cb46e3 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 12:30:14 +0700 Subject: [PATCH 1/8] refactor(builder): move flows, triggers, sequences, broadcasts, webhooks data access into business Removes direct db usage from flows, triggers, sequences, broadcasts, webhooks, the template resource picker and saved replies per .agents/rules/data-access.md. Public oRPC router keys, paths and the public-spec snapshot are unchanged. - new flow, sequence, broadcast, trigger, condition and template-selectable-resource repositories; webhook repository gains paginated list + detail reads - flowService.createWithDefaultDraft, flowVersionService.publish, triggerService/webhookService create/update/updateSettings/deleteMany (trigger and webhook condition-diff semantics kept separate), new sequenceService, broadcastService.create/resend, savedReplyService.listByWorkspaceId - broadcastService.create validates each integration id independently so the validation error lands on the field that failed - validationException added to errors.ts in the same form as #1093 - deleteSequence now also scopes by workspaceId (defense in depth) --- .../__tests__/create-broadcast.action.test.ts | 664 ++++-------------- .../__tests__/create-sequence.action.test.ts | 220 ++---- .../delete-sequence-step.action.test.ts | 209 ++---- .../__tests__/delete-sequence.action.test.ts | 137 +--- .../__tests__/delete-webhooks-action.test.ts | 61 +- .../__tests__/list-broadcast-audience.test.ts | 73 +- .../list-broadcasts-status-filter.test.ts | 81 --- .../list-selectable-resources.test.ts | 267 +++++++ .../public-list-queries-no-session.test.ts | 23 +- .../__tests__/publish-flow-action.test.ts | 209 +----- .../__tests__/resend-broadcast.action.test.ts | 225 ++---- .../__tests__/update-trigger-action.test.ts | 209 ++---- .../update-trigger-settings-action.test.ts | 103 +-- .../__tests__/update-webhook-action.test.ts | 128 ++-- .../update-webhook-settings-action.test.ts | 133 +--- .../upsert-sequence-step.action.test.ts | 323 +++------ .../flows/[id]/analytics/page.tsx | 13 +- .../space/[workspaceId]/flows/[id]/page.tsx | 13 +- .../actions/create-broadcast.action.ts | 176 +---- .../actions/resend-broadcast.action.ts | 60 +- .../src/features/broadcasts/queries/index.ts | 82 +-- .../flows/actions/publish-flow-action.ts | 88 +-- .../src/features/flows/queries/index.ts | 83 +-- .../features/saved-replies/queries/index.ts | 11 +- .../actions/create-sequence.action.ts | 26 +- .../actions/delete-sequence-step.action.ts | 42 +- .../actions/delete-sequence.action.ts | 28 +- .../actions/upsert-sequence-step.action.ts | 300 +------- .../src/features/sequences/queries/index.ts | 76 +- .../queries/list-selectable-resources.ts | 606 ++-------------- .../triggers/actions/update-trigger-action.ts | 138 +--- .../actions/update-trigger-settings-action.ts | 70 +- .../src/features/triggers/queries/index.ts | 73 +- .../actions/delete-webhooks-action.ts | 30 +- .../webhooks/actions/update-webhook-action.ts | 90 +-- .../actions/update-webhook-settings-action.ts | 56 +- .../src/features/webhooks/queries/index.ts | 77 +- .../broadcast-service-create.test.ts | 277 ++++++++ .../broadcast-service-resend.test.ts | 171 +++++ .../__tests__/flow-import-flow-export.test.ts | 5 + .../flow-version-service-publish.test.ts | 180 +++++ .../business/__tests__/flow.service.test.ts | 7 + .../__tests__/sequence-service.test.ts | 241 +++++++ .../trigger-service-update-settings.test.ts | 172 +++++ ...ger-service-update-with-conditions.test.ts | 222 ++++++ .../webhook-service-builder-methods.test.ts | 214 ++++++ .../__tests__/webhook.service.test.ts | 16 + packages/business/src/broadcast/service.ts | 180 ++++- packages/business/src/errors.ts | 7 + packages/business/src/flow-version/service.ts | 81 +++ packages/business/src/flow/service.ts | 16 + packages/business/src/index.ts | 1 + packages/business/src/saved-reply/service.ts | 9 + packages/business/src/sequence/index.ts | 1 + packages/business/src/sequence/service.ts | 164 +++++ .../business/src/sequence/step-payload.ts | 95 +++ .../business/src/trigger/condition-columns.ts | 20 + packages/business/src/trigger/service.ts | 201 +++++- packages/business/src/webhook/service.ts | 184 ++++- .../__tests__/broadcast-repository.test.ts | 201 ++++++ .../__tests__/flow-repository.test.ts | 132 ++++ ...ate-selectable-resource-repository.test.ts | 155 ++++ .../__tests__/trigger-repository.test.ts | 113 +++ .../src/repositories/broadcast/index.ts | 1 + .../src/repositories/broadcast/repository.ts | 150 ++++ .../src/repositories/condition/index.ts | 1 + .../src/repositories/condition/repository.ts | 21 + .../database/src/repositories/flow/index.ts | 1 + .../src/repositories/flow/repository.ts | 92 +++ packages/database/src/repositories/index.ts | 7 + .../src/repositories/sequence/index.ts | 1 + .../src/repositories/sequence/repository.ts | 106 +++ .../template-selectable-resource/index.ts | 1 + .../repository.ts | 496 +++++++++++++ .../src/repositories/trigger/index.ts | 1 + .../src/repositories/trigger/repository.ts | 86 +++ .../src/repositories/webhook/index.ts | 2 + .../src/repositories/webhook/repository.ts | 80 ++- .../whatsapp-message-template/index.ts | 1 + .../whatsapp-message-template/repository.ts | 20 + 80 files changed, 5266 insertions(+), 4069 deletions(-) delete mode 100644 apps/builder/__tests__/list-broadcasts-status-filter.test.ts create mode 100644 apps/builder/__tests__/list-selectable-resources.test.ts create mode 100644 packages/business/__tests__/broadcast-service-create.test.ts create mode 100644 packages/business/__tests__/broadcast-service-resend.test.ts create mode 100644 packages/business/__tests__/flow-version-service-publish.test.ts create mode 100644 packages/business/__tests__/sequence-service.test.ts create mode 100644 packages/business/__tests__/trigger-service-update-settings.test.ts create mode 100644 packages/business/__tests__/trigger-service-update-with-conditions.test.ts create mode 100644 packages/business/__tests__/webhook-service-builder-methods.test.ts create mode 100644 packages/business/src/sequence/index.ts create mode 100644 packages/business/src/sequence/service.ts create mode 100644 packages/business/src/sequence/step-payload.ts create mode 100644 packages/business/src/trigger/condition-columns.ts create mode 100644 packages/database/__tests__/broadcast-repository.test.ts create mode 100644 packages/database/__tests__/flow-repository.test.ts create mode 100644 packages/database/__tests__/template-selectable-resource-repository.test.ts create mode 100644 packages/database/__tests__/trigger-repository.test.ts create mode 100644 packages/database/src/repositories/broadcast/index.ts create mode 100644 packages/database/src/repositories/broadcast/repository.ts create mode 100644 packages/database/src/repositories/condition/index.ts create mode 100644 packages/database/src/repositories/condition/repository.ts create mode 100644 packages/database/src/repositories/flow/index.ts create mode 100644 packages/database/src/repositories/flow/repository.ts create mode 100644 packages/database/src/repositories/sequence/index.ts create mode 100644 packages/database/src/repositories/sequence/repository.ts create mode 100644 packages/database/src/repositories/template-selectable-resource/index.ts create mode 100644 packages/database/src/repositories/template-selectable-resource/repository.ts create mode 100644 packages/database/src/repositories/trigger/index.ts create mode 100644 packages/database/src/repositories/trigger/repository.ts create mode 100644 packages/database/src/repositories/whatsapp-message-template/index.ts create mode 100644 packages/database/src/repositories/whatsapp-message-template/repository.ts diff --git a/apps/builder/__tests__/create-broadcast.action.test.ts b/apps/builder/__tests__/create-broadcast.action.test.ts index 0d2c0d89f2..030785c5be 100644 --- a/apps/builder/__tests__/create-broadcast.action.test.ts +++ b/apps/builder/__tests__/create-broadcast.action.test.ts @@ -3,43 +3,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockDbInsert, - mockInsertReturning, - mockInsertValues, - mockFlowFindFirst, - mockMessengerTemplateFindFirst, - mockWhatsappTemplateFindFirst, - mockIntegrationMessengerFindFirst, - mockIntegrationWhatsappFindFirst, + mockCreate, mockReturnValidationErrors, - mockRecordAuditLog, -} = vi.hoisted(() => { - const mockInsertReturning = vi.fn() - const mockInsertValues = vi.fn() - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - const mockDbInsert = vi.fn() - mockDbInsert.mockReturnValue({ values: mockInsertValues }) - - const mockReturnValidationErrors = vi.fn( - (_schema: unknown, errs: unknown) => ({ __validationError: errs }), - ) - - return { - mockDbInsert, - mockInsertReturning, - mockInsertValues, - mockFlowFindFirst: vi.fn(), - mockMessengerTemplateFindFirst: vi.fn(), - mockWhatsappTemplateFindFirst: vi.fn(), - mockIntegrationMessengerFindFirst: vi.fn(), - mockIntegrationWhatsappFindFirst: vi.fn(), - mockReturnValidationErrors, - mockRecordAuditLog: vi.fn(), - } -}) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, + mockGetCurrentUserAndTargetWorkspace, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockReturnValidationErrors: vi.fn((_schema: unknown, errs: unknown) => ({ + __validationError: errs, + })), + mockGetCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ + targetWorkspaceMember: { permissions: ["emailAndPhone"] }, + }), })) vi.mock("@/lib/safe-action", () => { @@ -50,73 +24,29 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) +vi.mock("@chatbotx.io/business", () => ({ + broadcastService: { create: mockCreate }, +})) + vi.mock("next-safe-action", () => ({ returnValidationErrors: mockReturnValidationErrors, })) vi.mock("@/lib/auth/utils", () => ({ - getCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ - targetWorkspaceMember: { permissions: ["emailAndPhone"] }, - }), + getCurrentUserAndTargetWorkspace: mockGetCurrentUserAndTargetWorkspace, })) -vi.mock("@chatbotx.io/database/queries/contact-filter/permission", () => ({ - pruneEmailPhoneFilterConditions: (contactFilter: unknown) => - contactFilter ?? undefined, +vi.mock("@/features/contacts/permissions", () => ({ + canViewContactEmailAndPhone: vi.fn(() => true), })) -vi.mock("@chatbotx.io/database/client", async () => { - const { messengerMessageTemplateModel } = await import( - "@chatbotx.io/database/schema" - ) - return { - // `db.query.*.findFirst` is mocked directly below, so `where` conditions - // built with these are never evaluated by real drizzle — only need to - // not throw when called. - eq: (...args: unknown[]) => ({ eq: args }), - and: (...args: unknown[]) => ({ and: args }), - db: { - query: { - flowModel: { findFirst: mockFlowFindFirst }, - integrationMessengerModel: { - findFirst: mockIntegrationMessengerFindFirst, - }, - integrationWhatsappModel: { - findFirst: mockIntegrationWhatsappFindFirst, - }, - }, - insert: mockDbInsert, - // BroadcastService.load{Whatsapp,Messenger}TemplateDetail() joins the - // template to its integration via a select() chain rather than - // query.*.findFirst() — the "found template" mocks below stand in for - // the chain's terminal awaited value (an array with 0 or 1 rows). - select: () => ({ - from: (table: unknown) => ({ - innerJoin: () => ({ - where: () => ({ - limit: async () => { - const template = - table === messengerMessageTemplateModel - ? await mockMessengerTemplateFindFirst() - : await mockWhatsappTemplateFindFirst() - return template ? [template] : [] - }, - }), - }), - }), - }), - }, - } -}) +vi.mock("@/features/common/schema", () => ({ + workspaceIdrequestParams: [], +})) -vi.mock("@chatbotx.io/database/schema", async (importOriginal) => { - const actual = - await importOriginal() - return { - ...actual, - broadcastModel: { _: "broadcastModel" }, - } -}) +vi.mock("../src/features/broadcasts/schema/action", () => ({ + createBroadcastRequest: { __schema: "createBroadcastRequest" }, +})) const { createBroadcastAction } = await import( "../src/features/broadcasts/actions/create-broadcast.action" @@ -124,10 +54,8 @@ const { createBroadcastAction } = await import( const WORKSPACE_ID = "ws-1" -beforeEach(() => { - mockIntegrationMessengerFindFirst.mockResolvedValue({ id: "int-1" }) - mockIntegrationWhatsappFindFirst.mockResolvedValue({ id: "wa-int-1" }) -}) +type Handler = (props: unknown) => Promise +const callAction = createBroadcastAction as unknown as Handler const baseInput = { channel: "whatsapp" as const, @@ -137,297 +65,152 @@ const baseInput = { contactFilter: null, } -describe("createBroadcastAction — flowId validation", () => { - beforeEach(() => { - vi.clearAllMocks() - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - mockDbInsert.mockReturnValue({ values: mockInsertValues }) +const validationError = (field: string, message: string) => + Object.assign(new Error(message), { code: "validation", field }) + +beforeEach(() => { + vi.clearAllMocks() + mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({ + targetWorkspaceMember: { permissions: ["emailAndPhone"] }, }) +}) - test("returns validation error when flowId provided but flow not found", async () => { - mockFlowFindFirst.mockResolvedValue(undefined) +describe("createBroadcastAction — validation branches map to returnValidationErrors", () => { + test("channel", async () => { + mockCreate.mockRejectedValue( + validationError("channel", "Unsupported broadcast channel"), + ) - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { ...baseInput, flowId: "flow-123" }, + parsedInput: { ...baseInput, channel: "webchat" }, }) expect(mockReturnValidationErrors).toHaveBeenCalledOnce() const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ unknown, - { flowId: { _errors: string[] } }, + { channel: { _errors: string[] } }, ] - expect(errors.flowId._errors).toContain("Flow not found") + expect(errors.channel._errors).toContain("Unsupported broadcast channel") expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("sets broadcastName to flow.name when flow is found", async () => { - const mockFlow = { id: "flow-123", name: "My Flow" } - mockFlowFindFirst.mockResolvedValue(mockFlow) - const mockBroadcast = { id: "bc-1", name: "My Flow" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - await (createBroadcastAction as (props: unknown) => Promise)({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { ...baseInput, flowId: "flow-123" }, - }) - - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - name: string - } - expect(insertedValues.name).toBe("My Flow") - }) -}) - -describe("createBroadcastAction — messenger template validation", () => { - beforeEach(() => { - vi.clearAllMocks() - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - mockDbInsert.mockReturnValue({ values: mockInsertValues }) - }) - - test("returns validation error when messenger template not found", async () => { - mockMessengerTemplateFindFirst.mockResolvedValue(undefined) + test("subaction", async () => { + mockCreate.mockRejectedValue( + validationError("subaction", "Unsupported broadcast subaction"), + ) - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "messenger", - subaction: "messengerTemplateMessage", - templateId: "tpl-1", - integrationMessengerId: "int-1", - }, + parsedInput: baseInput, }) - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ unknown, - { templateId: { _errors: string[] } }, + { subaction: { _errors: string[] } }, ] - expect(errors.templateId._errors).toContain("Template not found") + expect(errors.subaction._errors).toContain( + "Unsupported broadcast subaction", + ) expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("sets broadcastName to template.name when messenger template found", async () => { - const mockTemplate = { id: "tpl-1", name: "Promo Template" } - mockMessengerTemplateFindFirst.mockResolvedValue(mockTemplate) - const mockBroadcast = { id: "bc-2", name: "Promo Template" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) + test("flowId — neither flow nor template selected", async () => { + mockCreate.mockRejectedValue( + validationError("flowId", "Either flow or template must be selected"), + ) - await (createBroadcastAction as (props: unknown) => Promise)({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "messenger", - subaction: "messengerTemplateMessage", - templateId: "tpl-1", - integrationMessengerId: "int-1", - }, + parsedInput: baseInput, }) - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - name: string - } - expect(insertedValues.name).toBe("Promo Template") - }) -}) - -describe("createBroadcastAction — whatsapp template validation", () => { - beforeEach(() => { - vi.clearAllMocks() - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - mockDbInsert.mockReturnValue({ values: mockInsertValues }) - }) - - test("returns validation error when whatsapp template not found", async () => { - mockWhatsappTemplateFindFirst.mockResolvedValue(undefined) - - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - subaction: "whatsappTemplateMessage", - channel: "whatsapp", - templateId: "tpl-2", - integrationWhatsappId: "wa-int-1", - }, - }) - - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ unknown, - { templateId: { _errors: string[] } }, + { flowId: { _errors: string[] } }, ] - expect(errors.templateId._errors).toContain("Template not found") + expect(errors.flowId._errors).toContain( + "Either flow or template must be selected", + ) expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("sets broadcastName to template.name when whatsapp template found", async () => { - const mockTemplate = { id: "tpl-2", name: "WA Promo" } - mockWhatsappTemplateFindFirst.mockResolvedValue(mockTemplate) - const mockBroadcast = { id: "bc-3", name: "WA Promo" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - await (createBroadcastAction as (props: unknown) => Promise)({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - subaction: "whatsappTemplateMessage", - channel: "whatsapp", - templateId: "tpl-2", - integrationWhatsappId: "wa-int-1", - }, - }) - - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - name: string - } - expect(insertedValues.name).toBe("WA Promo") - }) -}) - -describe("createBroadcastAction — happy path insert", () => { - beforeEach(() => { - vi.clearAllMocks() - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - mockDbInsert.mockReturnValue({ values: mockInsertValues }) - mockFlowFindFirst.mockResolvedValue({ id: "flow-1", name: "Flow Name" }) - }) - - test("inserts with status 'scheduled' and returns the broadcast", async () => { - const mockBroadcast = { id: "bc-4", name: "Broadcast", status: "scheduled" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) + test("flowId — flow not found", async () => { + mockCreate.mockRejectedValue(validationError("flowId", "Flow not found")) - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { ...baseInput, flowId: "flow-1" }, + parsedInput: { ...baseInput, flowId: "flow-123" }, }) - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - status: string - workspaceId: string - } - expect(insertedValues.status).toBe("scheduled") - expect(insertedValues.workspaceId).toBe(WORKSPACE_ID) - expect(result).toBe(mockBroadcast) - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "create", - detail: "created a new broadcast (#bc-4)", - }) - // schedulesType "now" in baseInput → also emits a launch row. - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "launch", - detail: "launched a broadcast (#bc-4)", - }) + const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ + unknown, + { flowId: { _errors: string[] } }, + ] + expect(errors.flowId._errors).toContain("Flow not found") + expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("does not emit a launch row for a future-scheduled broadcast", async () => { - const mockBroadcast = { id: "bc-future", name: "Broadcast" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - await (createBroadcastAction as (props: unknown) => Promise)({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - flowId: "flow-1", - schedulesType: "future", - schedulesAt: new Date().toISOString(), - }, - }) - - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "create", - detail: "created a new broadcast (#bc-future)", - }) - expect(mockRecordAuditLog).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "launch" }), + test("templateId — unsupported for channel", async () => { + mockCreate.mockRejectedValue( + validationError( + "templateId", + "Template broadcasts are not supported for this channel", + ), ) - }) - test("records only the create audit when saving as draft", async () => { - const mockBroadcast = { id: "bc-draft", name: "Broadcast" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - await (createBroadcastAction as (props: unknown) => Promise)({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], parsedInput: { ...baseInput, - flowId: "flow-1", - schedulesType: "now", - saveAsDraft: true, + channel: "tiktok", + templateId: "template-1", }, }) - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - status: string - } - expect(insertedValues.status).toBe("draft") - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "create", - detail: "created a new broadcast (#bc-draft)", - }) - expect(mockRecordAuditLog).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "launch" }), + const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ + unknown, + { templateId: { _errors: string[] } }, + ] + expect(errors.templateId._errors).toContain( + "Template broadcasts are not supported for this channel", ) + expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("persists integrationMessengerId so audience scoping matches the preview", async () => { - const mockBroadcast = { id: "bc-5", name: "Broadcast" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - mockIntegrationMessengerFindFirst.mockResolvedValue({ id: "int-999" }) + test("templateId — template not found", async () => { + mockCreate.mockRejectedValue( + validationError("templateId", "Template not found"), + ) - await (createBroadcastAction as (props: unknown) => Promise)({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "messenger", - subaction: "messengerActiveContacts", - flowId: "flow-1", - integrationMessengerId: "int-999", - }, + parsedInput: { ...baseInput, templateId: "tpl-1" }, }) - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record< - string, - unknown - > - expect(insertedValues.integrationMessengerId).toBe("int-999") - expect(mockIntegrationMessengerFindFirst).toHaveBeenCalledWith({ - where: { id: "int-999", workspaceId: WORKSPACE_ID }, - columns: { id: true }, - }) + const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ + unknown, + { templateId: { _errors: string[] } }, + ] + expect(errors.templateId._errors).toContain("Template not found") + expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("rejects a messenger integration from another workspace", async () => { - mockIntegrationMessengerFindFirst.mockResolvedValue(undefined) + test("integrationMessengerId — not owned by workspace", async () => { + mockCreate.mockRejectedValue( + validationError("integrationMessengerId", "Integration not found"), + ) - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], parsedInput: { ...baseInput, channel: "messenger", - subaction: "messengerActiveContacts", flowId: "flow-1", integrationMessengerId: "foreign-int", }, }) - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ unknown, { integrationMessengerId: { _errors: string[] } }, @@ -435,26 +218,23 @@ describe("createBroadcastAction — happy path insert", () => { expect(errors.integrationMessengerId._errors).toContain( "Integration not found", ) - expect(mockInsertValues).not.toHaveBeenCalled() expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("rejects a whatsapp integration from another workspace", async () => { - mockIntegrationWhatsappFindFirst.mockResolvedValue(undefined) + test("integrationWhatsappId — not owned by workspace", async () => { + mockCreate.mockRejectedValue( + validationError("integrationWhatsappId", "Integration not found"), + ) - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], parsedInput: { ...baseInput, flowId: "flow-1", - channel: "whatsapp", integrationWhatsappId: "foreign-wa-int", }, }) - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ unknown, { integrationWhatsappId: { _errors: string[] } }, @@ -462,228 +242,52 @@ describe("createBroadcastAction — happy path insert", () => { expect(errors.integrationWhatsappId._errors).toContain( "Integration not found", ) - expect(mockInsertValues).not.toHaveBeenCalled() expect(result).toMatchObject({ __validationError: expect.anything() }) }) +}) - test("merges templateData with buttons when templateData is provided", async () => { - const mockBroadcast = { id: "bc-6", name: "Broadcast" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - const templateData = { language: "en", components: [] } - const buttons = [{ id: "btn-1", label: "Click me" }] - - await (createBroadcastAction as (props: unknown) => Promise)({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - flowId: "flow-1", - templateData, - buttons, - }, - }) - - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - templateData: Record - } - expect(insertedValues.templateData).toMatchObject({ - language: "en", - components: [], - buttons: [{ id: "btn-1", label: "Click me" }], - }) - }) - - test("sets templateData to null when no templateData is provided", async () => { - const mockBroadcast = { id: "bc-7", name: "Broadcast" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) +describe("createBroadcastAction — happy path", () => { + test("passes canViewEmailAndPhone derived from the session and returns the created broadcast", async () => { + const mockBroadcast = { id: "bc-1", name: "Broadcast" } + mockCreate.mockResolvedValue(mockBroadcast) - await (createBroadcastAction as (props: unknown) => Promise)({ + const result = await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], parsedInput: { ...baseInput, flowId: "flow-1" }, }) - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - templateData: null - } - expect(insertedValues.templateData).toBeNull() - }) - - test.each([ - { - channel: "instagram" as const, - subaction: "instagramActiveContacts" as const, - }, - { - channel: "telegram" as const, - subaction: "telegramAllContacts" as const, - }, - { - channel: "tiktok" as const, - subaction: "tiktokActiveContacts" as const, - }, - ])("accepts $channel flow broadcasts", async ({ channel, subaction }) => { - const mockBroadcast = { id: `bc-${channel}` } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel, - subaction, - flowId: "flow-1", - }, - }) - expect(result).toBe(mockBroadcast) - expect(mockInsertValues).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ - channel, - subaction, + workspaceId: WORKSPACE_ID, flowId: "flow-1", + canViewEmailAndPhone: true, }), ) }) - test("rejects non-broadcastable channels such as Webchat", async () => { - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "webchat", - subaction: "allContacts", - flowId: "flow-1", - }, - }) - - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() - const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - { channel: { _errors: string[] } }, - ] - expect(errors.channel._errors).toContain("Unsupported broadcast channel") - expect(mockInsertValues).not.toHaveBeenCalled() - expect(result).toMatchObject({ __validationError: expect.anything() }) - }) + test("canViewEmailAndPhone is false when there is no session", async () => { + mockGetCurrentUserAndTargetWorkspace.mockResolvedValue(null) + mockCreate.mockResolvedValue({ id: "bc-2" }) - test("rejects template broadcasts for TikTok", async () => { - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ + await callAction({ bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "tiktok", - subaction: "tiktokActiveContacts", - templateId: "template-1", - }, - }) - - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() - const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - { templateId: { _errors: string[] } }, - ] - expect(errors.templateId._errors).toContain( - "Template broadcasts are not supported for this channel", - ) - expect(mockInsertValues).not.toHaveBeenCalled() - expect(result).toMatchObject({ __validationError: expect.anything() }) - }) - - test("rejects template broadcasts for Telegram", async () => { - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "telegram", - subaction: "telegramAllContacts", - templateId: "template-1", - }, - }) - - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() - const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - { templateId: { _errors: string[] } }, - ] - expect(errors.templateId._errors).toContain( - "Template broadcasts are not supported for this channel", - ) - expect(mockInsertValues).not.toHaveBeenCalled() - expect(result).toMatchObject({ __validationError: expect.anything() }) - }) - - test("rejects template broadcasts for Instagram", async () => { - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { - ...baseInput, - channel: "instagram", - subaction: "instagramActiveContacts", - templateId: "template-1", - }, + parsedInput: { ...baseInput, flowId: "flow-1" }, }) - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() - const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - { templateId: { _errors: string[] } }, - ] - expect(errors.templateId._errors).toContain( - "Template broadcasts are not supported for this channel", + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ canViewEmailAndPhone: false }), ) - expect(mockInsertValues).not.toHaveBeenCalled() - expect(result).toMatchObject({ __validationError: expect.anything() }) }) - test("schedulesAt is set to startOfMinute of the provided date string", async () => { - const mockBroadcast = { id: "bc-8" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - const schedulesAt = "2030-06-01T12:34:56.789Z" - - await (createBroadcastAction as (props: unknown) => Promise)({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { ...baseInput, flowId: "flow-1", schedulesAt }, - }) - - const insertedValues = mockInsertValues.mock.calls[0]?.[0] as { - schedulesAt: Date - } - expect(insertedValues.schedulesAt.getSeconds()).toBe(0) - expect(insertedValues.schedulesAt.getMilliseconds()).toBe(0) - expect(insertedValues.schedulesAt.getMinutes()).toBe(34) - }) + test("propagates a non-validation error", async () => { + mockCreate.mockRejectedValue(new Error("boom")) - test("rejects when neither flowId nor templateId is provided", async () => { - const mockBroadcast = { id: "bc-9" } - mockInsertReturning.mockResolvedValue([mockBroadcast]) - - const result = await ( - createBroadcastAction as (props: unknown) => Promise - )({ - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { ...baseInput }, - }) - - expect(mockReturnValidationErrors).toHaveBeenCalledOnce() - const [, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - { flowId: { _errors: string[] } }, - ] - expect(errors.flowId._errors).toContain( - "Either flow or template must be selected", - ) - expect(mockInsertValues).not.toHaveBeenCalled() - expect(result).toMatchObject({ __validationError: expect.anything() }) + await expect( + callAction({ + bindArgsParsedInputs: [WORKSPACE_ID], + parsedInput: { ...baseInput, flowId: "flow-1" }, + }), + ).rejects.toThrow("boom") }) }) diff --git a/apps/builder/__tests__/create-sequence.action.test.ts b/apps/builder/__tests__/create-sequence.action.test.ts index bd41439e68..e6b1d7655d 100644 --- a/apps/builder/__tests__/create-sequence.action.test.ts +++ b/apps/builder/__tests__/create-sequence.action.test.ts @@ -2,30 +2,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { - mockInsertValues, - mockInsert, - mockIsDatabaseError, - mockReturnValidationErrors, - mockGetTranslations, - mockCreateId, - mockCreateSequenceRequest, -} = vi.hoisted(() => { - const mockInsertValues = vi.fn().mockResolvedValue(undefined) - const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) - - return { - mockInsertValues, - mockInsert, - mockIsDatabaseError: vi.fn().mockReturnValue(false), +const { mockCreate, mockReturnValidationErrors, mockGetTranslations } = + vi.hoisted(() => ({ + mockCreate: vi.fn(), mockReturnValidationErrors: vi .fn() .mockReturnValue({ __validationError: true }), mockGetTranslations: vi.fn().mockResolvedValue((k: string) => k), - mockCreateId: vi.fn().mockReturnValue("test-id"), - mockCreateSequenceRequest: { __schema: "createSequenceRequest" }, - } -}) + })) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -35,23 +19,10 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { insert: mockInsert }, - isDatabaseError: mockIsDatabaseError, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceModel: { id: "id", name: "name", workspaceId: "workspaceId" }, +vi.mock("@chatbotx.io/business", () => ({ + sequenceService: { create: mockCreate }, })) -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createId: mockCreateId, - } -}) - vi.mock("next-intl/server", () => ({ getTranslations: mockGetTranslations, })) @@ -64,6 +35,7 @@ vi.mock("@/features/common/schema", () => ({ workspaceIdrequestParams: [], })) +const mockCreateSequenceRequest = { __schema: "createSequenceRequest" } vi.mock("@/features/sequences/schema/action", () => ({ createSequenceRequest: mockCreateSequenceRequest, })) @@ -72,7 +44,6 @@ const { createSequenceAction } = await import( "../src/features/sequences/actions/create-sequence.action" ) -// With the safe-action chain mock, the exported action IS the raw handler. type Handler = (args: { bindArgsParsedInputs: [string] parsedInput: { name: string; folderId?: string | null } @@ -85,164 +56,59 @@ const WS = "ws-1" describe("createSequenceAction", () => { beforeEach(() => { vi.clearAllMocks() - mockInsert.mockReturnValue({ values: mockInsertValues }) - mockInsertValues.mockResolvedValue(undefined) - mockIsDatabaseError.mockReturnValue(false) mockGetTranslations.mockResolvedValue((k: string) => k) - mockCreateId.mockReturnValue("test-id") mockReturnValidationErrors.mockReturnValue({ __validationError: true }) }) - describe("happy path", () => { - test("inserts sequence with correct fields and returns sequenceId", async () => { - // Arrange - const parsedInput = { name: "My Sequence", folderId: null } + test("delegates to sequenceService.create and returns its result", async () => { + mockCreate.mockResolvedValue({ sequenceId: "seq-1" }) - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput, - }) - - // Assert - expect(mockInsert).toHaveBeenCalledTimes(1) - expect(mockInsertValues).toHaveBeenCalledWith({ - id: "test-id", - workspaceId: WS, - name: "My Sequence", - folderId: null, - }) - expect(result).toEqual({ sequenceId: "test-id" }) + const result = await callAction({ + bindArgsParsedInputs: [WS], + parsedInput: { name: "My Sequence", folderId: null }, }) - test("uses createId result as the new sequence id", async () => { - // Arrange - mockCreateId.mockReturnValue("custom-id") - - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq" }, - }) - - // Assert - expect(mockCreateId).toHaveBeenCalledTimes(1) - expect((result as { sequenceId: string }).sequenceId).toBe("custom-id") + expect(mockCreate).toHaveBeenCalledWith({ + workspaceId: WS, + name: "My Sequence", + folderId: null, }) + expect(result).toEqual({ sequenceId: "seq-1" }) + }) - test("stores folderId as null when parsedInput.folderId is undefined", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq" }, - }) - - // Assert - const arg = mockInsertValues.mock.calls[0]?.[0] as { folderId: unknown } - expect(arg.folderId).toBeNull() + test("maps a validationException(name) to returnValidationErrors with the createSequenceRequest schema", async () => { + const validationError = Object.assign(new Error("Name is already taken."), { + code: "validation", + field: "name", }) + mockCreate.mockRejectedValue(validationError) - test("stores folderId as null when parsedInput.folderId is explicitly null", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq", folderId: null }, - }) - - // Assert - const arg = mockInsertValues.mock.calls[0]?.[0] as { folderId: unknown } - expect(arg.folderId).toBeNull() + const result = await callAction({ + bindArgsParsedInputs: [WS], + parsedInput: { name: "Duplicate" }, }) - test("passes folderId through when a non-null value is provided", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq", folderId: "folder-99" }, - }) + expect(mockReturnValidationErrors).toHaveBeenCalledTimes(1) + expect(result).toEqual({ __validationError: true }) - // Assert - const arg = mockInsertValues.mock.calls[0]?.[0] as { folderId: unknown } - expect(arg.folderId).toBe("folder-99") - }) + const [schema, errors] = mockReturnValidationErrors.mock.calls[0] as [ + unknown, + Record, + ] + expect(schema).toBe(mockCreateSequenceRequest) + expect(errors).toHaveProperty("_errors") + expect(errors).toHaveProperty("name._errors") }) - describe("unique violation (23505)", () => { - test("returns returnValidationErrors result on duplicate name", async () => { - // Arrange - const dbError = Object.assign(new Error("unique violation"), { - cause: { code: "23505" }, - }) - mockInsertValues.mockRejectedValue(dbError) - mockIsDatabaseError.mockReturnValue(true) - - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Duplicate", folderId: null }, - }) - - // Assert - expect(mockReturnValidationErrors).toHaveBeenCalledTimes(1) - expect(result).toEqual({ __validationError: true }) - }) - - test("calls returnValidationErrors with the createSequenceRequest schema", async () => { - // Arrange - const dbError = Object.assign(new Error("unique violation"), { - cause: { code: "23505" }, - }) - mockInsertValues.mockRejectedValue(dbError) - mockIsDatabaseError.mockReturnValue(true) + test("throws 'Failed to create sequence' for non-validation errors", async () => { + mockCreate.mockRejectedValue(new Error("network error")) - // Act - await callAction({ + await expect( + callAction({ bindArgsParsedInputs: [WS], - parsedInput: { name: "Duplicate" }, - }) - - // Assert — first argument is the schema, second is the errors object - const [schema, errors] = mockReturnValidationErrors.mock.calls[0] as [ - unknown, - Record, - ] - expect(schema).toBe(mockCreateSequenceRequest) - expect(errors).toHaveProperty("_errors") - expect(errors).toHaveProperty("name._errors") - }) - }) - - describe("non-23505 DB errors", () => { - test("throws 'Failed to create sequence' for non-23505 DB errors", async () => { - // Arrange - const dbError = Object.assign(new Error("other db"), { - cause: { code: "XXXXX" }, - }) - mockInsertValues.mockRejectedValue(dbError) - mockIsDatabaseError.mockReturnValue(true) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq", folderId: null }, - }), - ).rejects.toThrow("Failed to create sequence") - expect(mockReturnValidationErrors).not.toHaveBeenCalled() - }) - - test("throws 'Failed to create sequence' for non-DB errors", async () => { - // Arrange - mockInsertValues.mockRejectedValue(new Error("network error")) - mockIsDatabaseError.mockReturnValue(false) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { name: "Seq", folderId: null }, - }), - ).rejects.toThrow("Failed to create sequence") - }) + parsedInput: { name: "Seq", folderId: null }, + }), + ).rejects.toThrow("Failed to create sequence") + expect(mockReturnValidationErrors).not.toHaveBeenCalled() }) }) diff --git a/apps/builder/__tests__/delete-sequence-step.action.test.ts b/apps/builder/__tests__/delete-sequence-step.action.test.ts index dafd432e20..ee65f01739 100644 --- a/apps/builder/__tests__/delete-sequence-step.action.test.ts +++ b/apps/builder/__tests__/delete-sequence-step.action.test.ts @@ -3,24 +3,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockDeleteWhere, - mockDelete, - mockFindOrFail, - mockFindFirst, + mockAssertOwned, + mockDeleteStep, mockRecalculateAllContactsInSequence, -} = vi.hoisted(() => { - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere }) - const mockFindFirst = vi.fn() - - return { - mockDeleteWhere, - mockDelete, - mockFindOrFail: vi.fn().mockResolvedValue(undefined), - mockFindFirst, - mockRecalculateAllContactsInSequence: vi.fn().mockResolvedValue(undefined), - } -}) +} = vi.hoisted(() => ({ + mockAssertOwned: vi.fn().mockResolvedValue(undefined), + mockDeleteStep: vi.fn().mockResolvedValue(undefined), + mockRecalculateAllContactsInSequence: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -30,20 +20,11 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - sequenceStepModel: { findFirst: mockFindFirst }, - }, - delete: mockDelete, +vi.mock("@chatbotx.io/business", () => ({ + sequenceService: { + assertOwned: mockAssertOwned, + deleteStep: mockDeleteStep, }, - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - findOrFail: mockFindOrFail, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceModel: { id: "id", workspaceId: "workspaceId" }, - sequenceStepModel: { id: "id" }, })) vi.mock("@/features/common/schema", () => ({ @@ -58,7 +39,6 @@ const { deleteSequenceStepAction } = await import( "../src/features/sequences/actions/delete-sequence-step.action" ) -// With the safe-action chain mock, the exported action IS the raw handler. type ActionHandler = (args: { bindArgsParsedInputs: [string] parsedInput: { stepId: string; sequenceId: string } @@ -70,147 +50,72 @@ const WS = "ws-1" const SEQ_ID = "seq-1" const STEP_ID = "step-1" -/** Helper to produce a step object with the given workspace on its parent sequence */ -const makeStep = (workspaceId = WS) => ({ - id: STEP_ID, - sequence: { workspaceId }, -}) - describe("deleteSequenceStepAction", () => { beforeEach(() => { vi.clearAllMocks() - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue(undefined) - mockFindFirst.mockResolvedValue(makeStep()) + mockAssertOwned.mockResolvedValue(undefined) + mockDeleteStep.mockResolvedValue(undefined) mockRecalculateAllContactsInSequence.mockResolvedValue(undefined) }) - describe("happy path", () => { - test("validates sequence ownership, deletes step, and recalculates contacts", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }) - - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockFindFirst).toHaveBeenCalledTimes(1) - expect(mockDelete).toHaveBeenCalledTimes(1) - expect(mockRecalculateAllContactsInSequence).toHaveBeenCalledTimes(1) + test("validates sequence ownership, deletes step, and recalculates contacts", async () => { + const result = await callAction({ + bindArgsParsedInputs: [WS], + parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, }) - test("returns { success: true }", async () => { - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }) - - // Assert - expect(result).toEqual({ success: true }) + expect(mockAssertOwned).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, }) - - test("calls findOrFail with sequenceId and workspaceId for ownership validation", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }) - - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { id: string; workspaceId: string } - message: string - } - expect(args.where.id).toBe(SEQ_ID) - expect(args.where.workspaceId).toBe(WS) - expect(args.message).toBe("Sequence not found") + expect(mockDeleteStep).toHaveBeenCalledWith({ + workspaceId: WS, + stepId: STEP_ID, }) + expect(mockRecalculateAllContactsInSequence).toHaveBeenCalledWith( + SEQ_ID, + WS, + ) + expect(result).toEqual({ success: true }) + }) - test("calls recalculateAllContactsInSequence with sequenceId and workspaceId", async () => { - // Act - await callAction({ + test("propagates a sequence-not-found error and never deletes or recalculates", async () => { + mockAssertOwned.mockRejectedValue(new Error("Sequence not found")) + + await expect( + callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }) + }), + ).rejects.toThrow("Sequence not found") - // Assert - expect(mockRecalculateAllContactsInSequence).toHaveBeenCalledWith( - SEQ_ID, - WS, - ) - }) + expect(mockDeleteStep).not.toHaveBeenCalled() + expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() + }) + + test("propagates a step-not-found error and never recalculates", async () => { + mockDeleteStep.mockRejectedValue(new Error("Step not found")) - test("queries step with the provided stepId", async () => { - // Act - await callAction({ + await expect( + callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }) - - // Assert - const findArgs = mockFindFirst.mock.calls[0]?.[0] as { - where: { id: string } - with: { sequence: boolean } - } - expect(findArgs.where.id).toBe(STEP_ID) - expect(findArgs.with.sequence).toBe(true) - }) - }) + }), + ).rejects.toThrow("Step not found") - describe("sequence not found", () => { - test("throws when findOrFail rejects and does not delete or recalculate", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }), - ).rejects.toThrow("Sequence not found") - - expect(mockDelete).not.toHaveBeenCalled() - expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() - }) + expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() }) - describe("step not found", () => { - test("throws 'Step not found' when db query returns null", async () => { - // Arrange - mockFindFirst.mockResolvedValue(null) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }), - ).rejects.toThrow("Step not found") - - expect(mockDelete).not.toHaveBeenCalled() - expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() - }) - }) + test("propagates an unauthorized cross-workspace error", async () => { + mockDeleteStep.mockRejectedValue( + new Error("Unauthorized: Step does not belong to this workspace"), + ) - describe("workspace mismatch", () => { - test("throws unauthorized error when step belongs to a different workspace", async () => { - // Arrange - mockFindFirst.mockResolvedValue(makeStep("other-workspace")) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, - }), - ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") - - expect(mockDelete).not.toHaveBeenCalled() - expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() - }) + await expect( + callAction({ + bindArgsParsedInputs: [WS], + parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, + }), + ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") }) }) diff --git a/apps/builder/__tests__/delete-sequence.action.test.ts b/apps/builder/__tests__/delete-sequence.action.test.ts index 5db09b475b..60f3a62e4b 100644 --- a/apps/builder/__tests__/delete-sequence.action.test.ts +++ b/apps/builder/__tests__/delete-sequence.action.test.ts @@ -2,18 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { mockDeleteWhere, mockDelete, mockFindOrFail, mockAuditRecord } = - vi.hoisted(() => { - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere }) - - return { - mockDeleteWhere, - mockDelete, - mockFindOrFail: vi.fn().mockResolvedValue({ id: "seq-1", name: "Seq" }), - mockAuditRecord: vi.fn().mockResolvedValue(undefined), - } - }) +const { mockDelete } = vi.hoisted(() => ({ + mockDelete: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -23,27 +14,14 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/database/client", () => ({ - and: (...args: unknown[]) => ({ and: args }), - db: { delete: mockDelete }, - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - findOrFail: mockFindOrFail, -})) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mockAuditRecord }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceModel: { id: "id", workspaceId: "workspaceId" }, +vi.mock("@chatbotx.io/business", () => ({ + sequenceService: { delete: mockDelete }, })) -const { deleteSequenceAction, deleteSequence } = await import( +const { deleteSequenceAction } = await import( "../src/features/sequences/actions/delete-sequence.action" ) -// deleteSequenceAction uses bindArgsSchemas ONLY (no inputSchema). -// With the safe-action chain mock the exported value is the raw handler. type ActionHandler = (args: { bindArgsParsedInputs: [string, string] }) => Promise @@ -56,109 +34,20 @@ const SEQ_ID = "seq-1" describe("deleteSequenceAction", () => { beforeEach(() => { vi.clearAllMocks() - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue({ id: "seq-1", name: "Seq" }) - }) - - describe("happy path", () => { - test("calls findOrFail and db.delete for a valid sequence", async () => { - // Act - await callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }) - - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockDelete).toHaveBeenCalledTimes(1) - }) - - test("calls findOrFail with workspace-scoped where clause", async () => { - // Act - await callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }) - - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { id: string; workspaceId: string } - message: string - } - expect(args.where.id).toBe(SEQ_ID) - expect(args.where.workspaceId).toBe(WS) - expect(args.message).toBe("Sequence not found") - }) - - test("calls db.delete after successful findOrFail", async () => { - // Act - await callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }) - - // Assert – order: findOrFail is invoked before delete - const findOrFailOrder = mockFindOrFail.mock.invocationCallOrder[0] ?? -1 - const deleteOrder = mockDelete.mock.invocationCallOrder[0] ?? -2 - expect(findOrFailOrder).toBeLessThan(deleteOrder) - }) - }) - - describe("sequence not found", () => { - test("propagates findOrFail error and does not call db.delete", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) - - // Act & Assert - await expect( - callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }), - ).rejects.toThrow("Sequence not found") - expect(mockDelete).not.toHaveBeenCalled() - }) - - test("does not call db.delete.where when findOrFail throws", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("not found")) - - // Act - await callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }).catch(() => { - // intentionally swallow - }) - - // Assert - expect(mockDeleteWhere).not.toHaveBeenCalled() - }) - }) -}) - -describe("deleteSequence (exported helper)", () => { - beforeEach(() => { - vi.clearAllMocks() - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) - mockFindOrFail.mockResolvedValue({ id: "seq-1", name: "Seq" }) - }) - - test("is directly callable with ctx object", async () => { - // Act - await deleteSequence({ workspaceId: WS, id: SEQ_ID }) - - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockDelete).toHaveBeenCalledTimes(1) + mockDelete.mockResolvedValue(undefined) }) - test("scopes findOrFail to the provided workspaceId", async () => { - // Act - await deleteSequence({ workspaceId: "alt-ws", id: SEQ_ID }) + test("delegates to sequenceService.delete with workspaceId and id", async () => { + await callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }) - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { workspaceId: string } - } - expect(args.where.workspaceId).toBe("alt-ws") + expect(mockDelete).toHaveBeenCalledWith({ workspaceId: WS, id: SEQ_ID }) }) - test("does not delete when findOrFail rejects", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) + test("propagates a not-found error from the service", async () => { + mockDelete.mockRejectedValue(new Error("Sequence not found")) - // Act & Assert await expect( - deleteSequence({ workspaceId: WS, id: SEQ_ID }), + callAction({ bindArgsParsedInputs: [WS, SEQ_ID] }), ).rejects.toThrow("Sequence not found") - expect(mockDelete).not.toHaveBeenCalled() }) }) diff --git a/apps/builder/__tests__/delete-webhooks-action.test.ts b/apps/builder/__tests__/delete-webhooks-action.test.ts index 52ae588a1b..32448781c5 100644 --- a/apps/builder/__tests__/delete-webhooks-action.test.ts +++ b/apps/builder/__tests__/delete-webhooks-action.test.ts @@ -2,13 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { mockFindMany, mockDelete, mockRemoveWebhookCache, mockRecordAuditLog } = - vi.hoisted(() => ({ - mockFindMany: vi.fn(), - mockDelete: vi.fn(), - mockRemoveWebhookCache: vi.fn().mockResolvedValue(undefined), - mockRecordAuditLog: vi.fn(), - })) +const { mockDeleteMany } = vi.hoisted(() => ({ + mockDeleteMany: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -18,26 +14,8 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { webhookModel: { findMany: mockFindMany } }, - delete: mockDelete, - }, - and: (...args: unknown[]) => ({ __and: args }), - eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), - inArray: (a: unknown, b: unknown) => ({ __inArray: [a, b] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - webhookModel: { id: "id", workspaceId: "workspaceId" }, -})) - -vi.mock("@chatbotx.io/events", () => ({ - removeWebhookCache: mockRemoveWebhookCache, +vi.mock("@chatbotx.io/business", () => ({ + webhookService: { deleteMany: mockDeleteMany }, })) vi.mock("@/features/common/schema", () => ({ @@ -54,38 +32,23 @@ type Handler = (args: { parsedInput: { ids: string[] } }) => Promise +const callAction = deleteWebhooksAction as unknown as Handler + beforeEach(() => { vi.clearAllMocks() - mockDelete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockDeleteMany.mockResolvedValue(undefined) }) describe("deleteWebhooksAction", () => { - test("emits one delete audit row listing every deleted webhook", async () => { - mockFindMany.mockResolvedValue([ - { id: "webhook-1", name: "New Order" }, - { id: "webhook-2", name: "Refund Issued" }, - ]) - - await (deleteWebhooksAction as unknown as Handler)({ + test("delegates to webhookService.deleteMany with workspaceId and ids", async () => { + await callAction({ bindArgsParsedInputs: ["ws-1"], parsedInput: { ids: ["webhook-1", "webhook-2"] }, }) - expect(mockRecordAuditLog).toHaveBeenCalledWith({ + expect(mockDeleteMany).toHaveBeenCalledWith({ workspaceId: "ws-1", - action: "delete", - detail: "deleted webhooks (#webhook-1, #webhook-2)", + ids: ["webhook-1", "webhook-2"], }) }) - - test("emits no audit row when nothing matched", async () => { - mockFindMany.mockResolvedValue([]) - - await (deleteWebhooksAction as unknown as Handler)({ - bindArgsParsedInputs: ["ws-1"], - parsedInput: { ids: ["missing"] }, - }) - - expect(mockRecordAuditLog).not.toHaveBeenCalled() - }) }) diff --git a/apps/builder/__tests__/list-broadcast-audience.test.ts b/apps/builder/__tests__/list-broadcast-audience.test.ts index 78bb5242b1..b2e7cbe4e7 100644 --- a/apps/builder/__tests__/list-broadcast-audience.test.ts +++ b/apps/builder/__tests__/list-broadcast-audience.test.ts @@ -3,38 +3,23 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockFindFirstBroadcast, - mockFindManyContacts, - mockCount, - mockEq, + mockFindIdIfActive, + mockListAudience, + mockCountAudience, mockNotFoundException, } = vi.hoisted(() => ({ - mockFindFirstBroadcast: vi.fn(), - mockFindManyContacts: vi.fn().mockResolvedValue([]), - mockCount: vi.fn().mockResolvedValue(0), - mockEq: vi.fn((a: unknown, b: unknown) => ({ __eq: [a, b] })), + mockFindIdIfActive: vi.fn(), + mockListAudience: vi.fn().mockResolvedValue([]), + mockCountAudience: vi.fn().mockResolvedValue(0), mockNotFoundException: vi.fn((message: string) => new Error(message)), })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findFirst: mockFindFirstBroadcast, - }, - contactsOnBroadcastsModel: { - findMany: mockFindManyContacts, - }, - }, - $count: mockCount, +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + findIdIfActive: mockFindIdIfActive, + listAudience: mockListAudience, + countAudience: mockCountAudience, }, - eq: mockEq, - relationsFilterToSQL: vi.fn(), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { id: "broadcastModelId" }, - contactsOnBroadcastsModel: { broadcastId: "contactsOnBroadcastsBroadcastId" }, })) vi.mock("@chatbotx.io/database/utils", () => ({ @@ -61,12 +46,12 @@ const { listBroadcastAudience } = await import( describe("listBroadcastAudience deletedAt gate", () => { beforeEach(() => { vi.clearAllMocks() - mockFindManyContacts.mockResolvedValue([]) - mockCount.mockResolvedValue(0) + mockListAudience.mockResolvedValue([]) + mockCountAudience.mockResolvedValue(0) }) test("looks up the broadcast scoped to workspaceId + id + deletedAt IS NULL before listing recipients", async () => { - mockFindFirstBroadcast.mockResolvedValue({ id: "b-1" }) + mockFindIdIfActive.mockResolvedValue({ id: "b-1" }) await listBroadcastAudience({ broadcastId: "b-1", @@ -75,19 +60,15 @@ describe("listBroadcastAudience deletedAt gate", () => { perPage: 10, }) - expect(mockFindFirstBroadcast).toHaveBeenCalledWith({ - where: { - id: "b-1", - workspaceId: "ws-1", - deletedAt: { isNull: true }, - }, - columns: { id: true }, + expect(mockFindIdIfActive).toHaveBeenCalledWith({ + id: "b-1", + workspaceId: "ws-1", }) - expect(mockFindManyContacts).toHaveBeenCalled() + expect(mockListAudience).toHaveBeenCalled() }) test("throws not-found for a soft-deleted broadcast and never queries recipients", async () => { - mockFindFirstBroadcast.mockResolvedValue(undefined) + mockFindIdIfActive.mockResolvedValue(undefined) await expect( listBroadcastAudience({ @@ -99,12 +80,12 @@ describe("listBroadcastAudience deletedAt gate", () => { ).rejects.toThrow("Broadcast not found") expect(mockNotFoundException).toHaveBeenCalledWith("Broadcast not found") - expect(mockFindManyContacts).not.toHaveBeenCalled() - expect(mockCount).not.toHaveBeenCalled() + expect(mockListAudience).not.toHaveBeenCalled() + expect(mockCountAudience).not.toHaveBeenCalled() }) test("throws not-found when the broadcast exists but belongs to a different workspace", async () => { - mockFindFirstBroadcast.mockResolvedValue(undefined) + mockFindIdIfActive.mockResolvedValue(undefined) await expect( listBroadcastAudience({ @@ -115,13 +96,9 @@ describe("listBroadcastAudience deletedAt gate", () => { }), ).rejects.toThrow("Broadcast not found") - expect(mockFindFirstBroadcast).toHaveBeenCalledWith({ - where: { - id: "b-1", - workspaceId: "ws-foreign", - deletedAt: { isNull: true }, - }, - columns: { id: true }, + expect(mockFindIdIfActive).toHaveBeenCalledWith({ + id: "b-1", + workspaceId: "ws-foreign", }) }) }) diff --git a/apps/builder/__tests__/list-broadcasts-status-filter.test.ts b/apps/builder/__tests__/list-broadcasts-status-filter.test.ts deleted file mode 100644 index 3a0a9fc6f2..0000000000 --- a/apps/builder/__tests__/list-broadcasts-status-filter.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, test, vi } from "vitest" - -const { mockFindMany, mockCount, mockRelationsFilterToSQL, mockEq } = - vi.hoisted(() => ({ - mockFindMany: vi.fn().mockResolvedValue([]), - mockCount: vi.fn().mockResolvedValue(0), - mockRelationsFilterToSQL: vi.fn(), - mockEq: vi.fn(), - })) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - broadcastModel: { - findMany: mockFindMany, - }, - }, - $count: mockCount, - }, - eq: mockEq, - relationsFilterToSQL: mockRelationsFilterToSQL, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { id: "broadcastModelId" }, - contactsOnBroadcastsModel: { id: "contactsOnBroadcastsModelId" }, -})) - -vi.mock("@chatbotx.io/database/utils", () => ({ - getPaginationWithDefaults: (input: { page?: number; perPage?: number }) => ({ - limit: input.perPage ?? 10, - offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), - }), - likeContains: (value: string) => value, - parseOrderByAsObject: () => undefined, -})) - -const { listBroadcasts } = await import( - "../src/features/broadcasts/queries/index" -) - -describe("listBroadcasts status filter", () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindMany.mockResolvedValue([]) - mockCount.mockResolvedValue(0) - }) - - test("filters by status when provided", async () => { - await listBroadcasts({ - workspaceId: "ws-1", - page: 1, - perPage: 10, - name: null, - sort: [{ id: "createdAt", desc: true }], - status: "failed", - }) - - expect(mockFindMany.mock.calls[0]?.[0].where).toEqual({ - workspaceId: "ws-1", - name: undefined, - status: "failed", - deletedAt: { isNull: true }, - }) - }) - - test("omits status from where clause when null", async () => { - await listBroadcasts({ - workspaceId: "ws-1", - page: 1, - perPage: 10, - name: null, - sort: [{ id: "createdAt", desc: true }], - status: null, - }) - - expect(mockFindMany.mock.calls[0]?.[0].where.status).toBeUndefined() - }) -}) diff --git a/apps/builder/__tests__/list-selectable-resources.test.ts b/apps/builder/__tests__/list-selectable-resources.test.ts new file mode 100644 index 0000000000..300bd870bb --- /dev/null +++ b/apps/builder/__tests__/list-selectable-resources.test.ts @@ -0,0 +1,267 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { mockListFlows, mockListTags, mockListKeywords, mockListSettings } = + vi.hoisted(() => ({ + mockListFlows: vi.fn(), + mockListTags: vi.fn(), + mockListKeywords: vi.fn(), + mockListSettings: vi.fn(), + })) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + templateSelectableResourceRepository: { + listFlows: mockListFlows, + listTags: mockListTags, + listCustomFields: vi.fn(), + listProducts: vi.fn(), + listAIFunctions: vi.fn(), + listAIAgents: vi.fn(), + listCalendars: vi.fn(), + listWebchats: vi.fn(), + listTriggers: vi.fn(), + listFbCommentAutomations: vi.fn(), + listKeywords: mockListKeywords, + listEntryPointLinks: vi.fn(), + listSettings: mockListSettings, + }, +})) + +const { listSelectableResources } = await import( + "../src/features/templates/queries/list-selectable-resources" +) + +const WS = "ws-1" + +describe("listSelectableResources — switch dispatch", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("dispatches 'flows' to templateSelectableResourceRepository.listFlows", async () => { + mockListFlows.mockResolvedValue({ + rows: [{ id: "flow-1", name: "Flow 1" }], + total: 1, + allIds: ["flow-1"], + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "flows", + }) + + expect(mockListFlows).toHaveBeenCalledWith({ + workspaceId: WS, + keyword: undefined, + offset: 0, + limit: 100, + }) + expect(result.items).toEqual([{ id: "flow-1", name: "Flow 1" }]) + expect(result.total).toBe(1) + expect(result.allIds).toEqual(["flow-1"]) + }) + + test("dispatches 'tags' to templateSelectableResourceRepository.listTags", async () => { + mockListTags.mockResolvedValue({ rows: [], total: 0 }) + + await listSelectableResources({ workspaceId: WS, category: "tags" }) + + expect(mockListTags).toHaveBeenCalled() + }) + + test("returns an empty result for an unknown category", async () => { + const result = await listSelectableResources({ + workspaceId: WS, + category: "unknownCategory" as never, + }) + + expect(result).toEqual({ items: [], nextCursor: null, total: 0 }) + }) +}) + +describe("listSelectableResources — pagination math", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("computes nextCursor as offset + limit when more rows remain", async () => { + mockListFlows.mockResolvedValue({ + rows: [{ id: "flow-1", name: "Flow 1" }], + total: 150, + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "flows", + limit: 100, + }) + + expect(result.nextCursor).toBe("100") + }) + + test("returns null nextCursor when the page reaches the total", async () => { + mockListFlows.mockResolvedValue({ + rows: [{ id: "flow-1", name: "Flow 1" }], + total: 1, + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "flows", + }) + + expect(result.nextCursor).toBeNull() + }) + + test("parses the cursor into an offset for the next page", async () => { + mockListFlows.mockResolvedValue({ rows: [], total: 0 }) + + await listSelectableResources({ + workspaceId: WS, + category: "flows", + cursor: "100", + }) + + expect(mockListFlows).toHaveBeenCalledWith( + expect.objectContaining({ offset: 100 }), + ) + }) + + test("uses the custom limit when provided", async () => { + mockListFlows.mockResolvedValue({ rows: [], total: 0 }) + + await listSelectableResources({ + workspaceId: WS, + category: "flows", + limit: 25, + }) + + expect(mockListFlows).toHaveBeenCalledWith( + expect.objectContaining({ limit: 25 }), + ) + }) +}) + +describe("listSelectableResources — allIds passthrough", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("passes through allIds when the repository includes them", async () => { + mockListFlows.mockResolvedValue({ + rows: [{ id: "flow-1", name: "Flow 1" }], + total: 1, + allIds: ["flow-1"], + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "flows", + }) + + expect(result.allIds).toEqual(["flow-1"]) + }) + + test("omits allIds when the repository does not include them (offset > 0 or over the cap)", async () => { + mockListFlows.mockResolvedValue({ + rows: [{ id: "flow-1", name: "Flow 1" }], + total: 2000, + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "flows", + cursor: "100", + }) + + expect(result.allIds).toBeUndefined() + }) +}) + +describe("listSelectableResources — keywords toLabel fallback", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("the repository's projected 'name' already carries the toLabel fallback for keywords", async () => { + mockListKeywords.mockResolvedValue({ + rows: [ + { id: "kw-1", name: "hello" }, + { id: "kw-2", name: "bye, later" }, + { id: "kw-3", name: "(untitled)" }, + ], + total: 3, + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "keywords", + }) + + expect(result.items).toEqual([ + { id: "kw-1", name: "hello" }, + { id: "kw-2", name: "bye, later" }, + { id: "kw-3", name: "(untitled)" }, + ]) + }) +}) + +describe("listSelectableResources — settings merge/sort/paginate", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("merges saved replies and bot fields, sorts by name, and paginates in memory", async () => { + mockListSettings.mockResolvedValue({ + savedReplies: [ + { id: "sr-1", shortcut: "zeta" }, + { id: "sr-2", shortcut: "alpha" }, + ], + botFields: [{ id: "bf-1", name: "middle" }], + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "settings", + limit: 2, + }) + + expect(result.items).toEqual([ + { id: "sr-2", name: "alpha" }, + { id: "bf-1", name: "middle" }, + ]) + expect(result.total).toBe(3) + expect(result.nextCursor).toBe("2") + }) + + test("filters settings by keyword case-insensitively", async () => { + mockListSettings.mockResolvedValue({ + savedReplies: [{ id: "sr-1", shortcut: "Hello World" }], + botFields: [{ id: "bf-1", name: "Age" }], + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "settings", + keyword: "hello", + }) + + expect(result.items).toEqual([{ id: "sr-1", name: "Hello World" }]) + expect(result.total).toBe(1) + }) + + test("returns allIds for settings when under the cap at offset 0", async () => { + mockListSettings.mockResolvedValue({ + savedReplies: [{ id: "sr-1", shortcut: "alpha" }], + botFields: [], + }) + + const result = await listSelectableResources({ + workspaceId: WS, + category: "settings", + }) + + expect(result.allIds).toEqual(["sr-1"]) + }) +}) diff --git a/apps/builder/__tests__/public-list-queries-no-session.test.ts b/apps/builder/__tests__/public-list-queries-no-session.test.ts index 02ab0464bc..99946635c6 100644 --- a/apps/builder/__tests__/public-list-queries-no-session.test.ts +++ b/apps/builder/__tests__/public-list-queries-no-session.test.ts @@ -16,6 +16,10 @@ const mocks = vi.hoisted(() => ({ listByWorkspace: vi.fn().mockResolvedValue([]), findManyQuery: vi.fn().mockResolvedValue([]), findLastByConversation: vi.fn().mockResolvedValue([]), + broadcastListWithRelations: vi.fn().mockResolvedValue([]), + broadcastCount: vi.fn().mockResolvedValue(0), + sequenceListWithCounts: vi.fn().mockResolvedValue([]), + sequenceCount: vi.fn().mockResolvedValue(0), })) vi.mock("@/lib/auth/utils", () => ({ @@ -25,8 +29,6 @@ vi.mock("@/lib/auth/utils", () => ({ vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { - broadcastModel: { findMany: mocks.findMany }, - sequenceModel: { findMany: mocks.findMany }, errorLogModel: { findMany: mocks.findMany }, workspaceMemberModel: { findMany: mocks.findMany }, }, @@ -37,11 +39,6 @@ vi.mock("@chatbotx.io/database/client", () => ({ })) vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { id: "broadcastModelId" }, - contactsOnBroadcastsModel: { id: "contactsOnBroadcastsModelId" }, - sequenceModel: { id: "sequenceModelId" }, - sequenceStepModel: { id: "sequenceStepModelId" }, - contactsOnSequenceModel: { id: "contactsOnSequenceModelId" }, errorLogModel: { id: "errorLogModelId" }, workspaceMemberModel: { id: "workspaceMemberModelId" }, })) @@ -80,6 +77,14 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ createMessageRepository: vi.fn().mockResolvedValue({ findLastByConversation: mocks.findLastByConversation, }), + broadcastRepository: { + listWithRelations: mocks.broadcastListWithRelations, + count: mocks.broadcastCount, + }, + sequenceRepository: { + listWithCounts: mocks.sequenceListWithCounts, + count: mocks.sequenceCount, + }, })) vi.mock( @@ -96,6 +101,10 @@ beforeEach(() => { mocks.listByWorkspace.mockResolvedValue([]) mocks.findManyQuery.mockResolvedValue([]) mocks.findLastByConversation.mockResolvedValue([]) + mocks.broadcastListWithRelations.mockResolvedValue([]) + mocks.broadcastCount.mockResolvedValue(0) + mocks.sequenceListWithCounts.mockResolvedValue([]) + mocks.sequenceCount.mockResolvedValue(0) }) describe("public list queries never depend on a session", () => { diff --git a/apps/builder/__tests__/publish-flow-action.test.ts b/apps/builder/__tests__/publish-flow-action.test.ts index 89e8402b23..b9b580a562 100644 --- a/apps/builder/__tests__/publish-flow-action.test.ts +++ b/apps/builder/__tests__/publish-flow-action.test.ts @@ -3,37 +3,9 @@ import { sendMessageNodeDefaultFn } from "@chatbotx.io/flow-config" import { beforeEach, describe, expect, test, vi } from "vitest" -const { - mockFlowFindFirst, - mockDbTransaction, - mockTxInsert, - mockTxInsertValues, - mockTxUpdate, - mockTxSet, - mockTxWhere, - mockInvalidateList, - mockCreateId, - mockAuditRecord, -} = vi.hoisted(() => { - const mockTxInsertValues = vi.fn().mockResolvedValue(undefined) - const mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }) - const mockTxWhere = vi.fn().mockResolvedValue(undefined) - const mockTxSet = vi.fn().mockReturnValue({ where: mockTxWhere }) - const mockTxUpdate = vi.fn().mockReturnValue({ set: mockTxSet }) - - return { - mockFlowFindFirst: vi.fn(), - mockDbTransaction: vi.fn(), - mockTxInsert, - mockTxInsertValues, - mockTxUpdate, - mockTxSet, - mockTxWhere, - mockInvalidateList: vi.fn().mockResolvedValue(undefined), - mockCreateId: vi.fn(), - mockAuditRecord: vi.fn().mockResolvedValue(undefined), - } -}) +const { mockPublish } = vi.hoisted(() => ({ + mockPublish: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -44,148 +16,26 @@ vi.mock("@/lib/safe-action", () => { }) vi.mock("@chatbotx.io/business", () => ({ - flowVersionService: { invalidateList: mockInvalidateList }, -})) - -vi.mock("@chatbotx.io/business/errors", () => ({ - notFoundException: (message: string) => new Error(message), -})) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mockAuditRecord }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { flowModel: { findFirst: mockFlowFindFirst } }, - transaction: mockDbTransaction, - }, - and: (...args: unknown[]) => ({ and: args }), - eq: (...args: unknown[]) => ({ eq: args }), + flowVersionService: { publish: mockPublish }, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flowModel.id" }, - flowVersionModel: { - id: "flowVersionModel.id", - flowId: "flowVersionModel.flowId", - isLatest: "flowVersionModel.isLatest", - }, -})) - -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const original = (await importOriginal()) as Record - return { ...original, createId: mockCreateId } -}) - -const { publishFlow } = await import( +const { publishFlowAction } = await import( "../src/features/flows/actions/publish-flow-action" ) -const findInsertedVersion = () => - mockTxInsertValues.mock.calls[0]?.[0] as { - id: string - nodes: Array<{ id: string }> - startNodeId: string - isDraft: boolean - isLatest: boolean - } +type ActionHandler = (args: { + bindArgsParsedInputs: [string, string] + parsedInput: { nodes: unknown[]; edges: unknown[] } +}) => Promise -const findDraftUpdateValue = () => { - const call = mockTxSet.mock.calls.find( - ([value]) => value && "nodes" in (value as object), - ) - return call?.[0] as { nodes: Array<{ id: string }> } | undefined -} +const callAction = publishFlowAction as unknown as ActionHandler -describe("publishFlow", () => { +describe("publishFlowAction", () => { beforeEach(() => { vi.clearAllMocks() - mockTxInsertValues.mockResolvedValue(undefined) - mockTxInsert.mockReturnValue({ values: mockTxInsertValues }) - mockTxWhere.mockResolvedValue(undefined) - mockTxSet.mockReturnValue({ where: mockTxWhere }) - mockTxUpdate.mockReturnValue({ set: mockTxSet }) - mockCreateId.mockReturnValue("new-version-id") - mockDbTransaction.mockImplementation( - async ( - fn: (tx: { - insert: typeof mockTxInsert - update: typeof mockTxUpdate - }) => Promise, - ) => fn({ insert: mockTxInsert, update: mockTxUpdate }), - ) }) - test("publishes the current input nodes and updates the draft, ignoring stale draft data", async () => { - const staleNode = sendMessageNodeDefaultFn({ - nodeProps: { id: "1", position: { x: 0, y: 0 } }, - dataProps: { name: "Stale draft", isStartNode: true }, - detailProps: { - beforeStep: { - id: "11", - stepType: "chooseChannel", - channel: "omnichannel", - }, - }, - }) - const currentNode = sendMessageNodeDefaultFn({ - nodeProps: { id: "2", position: { x: 100, y: 100 } }, - dataProps: { name: "Current canvas", isStartNode: true }, - detailProps: { - beforeStep: { - id: "12", - stepType: "chooseChannel", - channel: "omnichannel", - }, - }, - }) - - mockFlowFindFirst.mockResolvedValue({ - id: "10", - workspaceId: "1", - flowVersions: [ - { - id: "100", - startNodeId: "1", - nodes: [staleNode], - edges: [], - }, - ], - }) - - await publishFlow( - { workspaceId: "1", id: "10" }, - { nodes: [currentNode], edges: [] }, - ) - - const inserted = findInsertedVersion() - expect(inserted.isDraft).toBe(false) - expect(inserted.isLatest).toBe(true) - expect(inserted.startNodeId).toBe("1") - expect(inserted.nodes).toEqual([ - expect.objectContaining({ - id: "2", - data: expect.objectContaining({ name: "Current canvas" }), - }), - ]) - - const draftUpdate = findDraftUpdateValue() - expect(draftUpdate?.nodes).toEqual([expect.objectContaining({ id: "2" })]) - - expect(mockInvalidateList).toHaveBeenCalledWith("10") - }) - - /** - * `Flow.currentVersionId` is how an unpinned run and a magic-link click both - * find the live version (`detectFlowVersion`, - * `flowVersionService.findForButtonPayload`). If publish ever stopped - * repointing it — relying on the `isLatest` flag alone, say — both would keep - * serving the *previous* version's nodes, which is the exact "buttons still - * fire the old action after publish" bug. Pinned here because the flag and the - * column are written by two separate statements. - */ - test("repoints the flow at the version it just inserted", async () => { + test("delegates to flowVersionService.publish with workspaceId, flowId, nodes, edges", async () => { const node = sendMessageNodeDefaultFn({ nodeProps: { id: "2", position: { x: 0, y: 0 } }, dataProps: { name: "Canvas", isStartNode: true }, @@ -197,31 +47,28 @@ describe("publishFlow", () => { }, }, }) - mockFlowFindFirst.mockResolvedValue({ - id: "10", - workspaceId: "1", - flowVersions: [{ id: "100", startNodeId: "2", nodes: [], edges: [] }], - }) - - await publishFlow( - { workspaceId: "1", id: "10" }, - { nodes: [node], edges: [] }, - ) - const inserted = findInsertedVersion() - expect(mockTxSet).toHaveBeenCalledWith({ currentVersionId: inserted.id }) - expect(inserted.isLatest).toBe(true) - }) + await callAction({ + bindArgsParsedInputs: ["1", "10"], + parsedInput: { nodes: [node], edges: [] }, + }) - test("throws when the flow has no draft version", async () => { - mockFlowFindFirst.mockResolvedValue({ - id: "10", + expect(mockPublish).toHaveBeenCalledWith({ workspaceId: "1", - flowVersions: [], + flowId: "10", + nodes: [node], + edges: [], }) + }) + + test("propagates errors thrown by the service (e.g. flow not found)", async () => { + mockPublish.mockRejectedValueOnce(new Error("Flow not found")) await expect( - publishFlow({ workspaceId: "1", id: "10" }, { nodes: [], edges: [] }), + callAction({ + bindArgsParsedInputs: ["1", "10"], + parsedInput: { nodes: [], edges: [] }, + }), ).rejects.toThrow("Flow not found") }) }) diff --git a/apps/builder/__tests__/resend-broadcast.action.test.ts b/apps/builder/__tests__/resend-broadcast.action.test.ts index 6f352aca29..f66b8eaf39 100644 --- a/apps/builder/__tests__/resend-broadcast.action.test.ts +++ b/apps/builder/__tests__/resend-broadcast.action.test.ts @@ -3,42 +3,15 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockFindOrFail, - mockDbTransaction, - mockTxInsert, - mockTxInsertValues, - mockTxInsertReturning, - mockCreateId, - MockChatbotXException, - mockRecordAuditLog, -} = vi.hoisted(() => { - const mockTxInsertReturning = vi.fn() - const mockTxInsertValues = vi.fn() - mockTxInsertValues.mockReturnValue({ returning: mockTxInsertReturning }) - const mockTxInsert = vi.fn() - mockTxInsert.mockReturnValue({ values: mockTxInsertValues }) - - class MockChatbotXException extends Error { - constructor(message: string) { - super(message) - this.name = "ChatbotXException" - } - } - - return { - mockFindOrFail: vi.fn(), - mockDbTransaction: vi.fn(), - mockTxInsert, - mockTxInsertValues, - mockTxInsertReturning, - mockCreateId: vi.fn().mockReturnValue("new-bc-id"), - MockChatbotXException, - mockRecordAuditLog: vi.fn(), - } -}) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, + mockResend, + mockFindContactFilter, + mockGetCurrentUserAndTargetWorkspace, +} = vi.hoisted(() => ({ + mockResend: vi.fn(), + mockFindContactFilter: vi.fn(), + mockGetCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ + targetWorkspaceMember: { permissions: ["emailAndPhone"] }, + }), })) vi.mock("@/lib/safe-action", () => { @@ -49,14 +22,12 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/errors", () => ({ - ChatbotXException: MockChatbotXException, +vi.mock("@chatbotx.io/business", () => ({ + broadcastService: { resend: mockResend }, })) -vi.mock("@/lib/auth/utils", () => ({ - getCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ - targetWorkspaceMember: { permissions: ["emailAndPhone"] }, - }), +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { findContactFilter: mockFindContactFilter }, })) vi.mock("@chatbotx.io/database/queries/contact-filter/permission", () => ({ @@ -64,24 +35,19 @@ vi.mock("@chatbotx.io/database/queries/contact-filter/permission", () => ({ contactFilter ?? undefined, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - transaction: mockDbTransaction, - }, - findOrFail: mockFindOrFail, +vi.mock("@/lib/auth/utils", () => ({ + getCurrentUserAndTargetWorkspace: mockGetCurrentUserAndTargetWorkspace, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { _: "broadcastModel" }, +vi.mock("@/features/contacts/permissions", () => ({ + canViewContactEmailAndPhone: vi.fn(() => true), })) -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const original = (await importOriginal()) as Record - return { - ...original, - createId: mockCreateId, - } -}) +vi.mock("@/features/contact-filter/schema", () => ({ + contactFilterCriteriaSchema: { + safeParse: (value: unknown) => ({ success: true, data: value }), + }, +})) const { resendBroadcast } = await import( "../src/features/broadcasts/actions/resend-broadcast.action" @@ -90,145 +56,64 @@ const { resendBroadcast } = await import( const WORKSPACE_ID = "ws-1" const BROADCAST_ID = "bc-1" -const baseBroadcast = { - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - name: "Summer Sale", - status: "sent" as const, - channel: "whatsapp" as const, - flowId: "flow-1", - integrationWhatsappId: "wa-1", - integrationMessengerId: "msg-1", - subaction: "whatsappWithin24Hours" as const, - templateId: null, - templateData: null, - schedulesType: "now" as const, - contactFilter: null, -} - describe("resendBroadcast", () => { beforeEach(() => { vi.clearAllMocks() - mockTxInsertValues.mockReturnValue({ returning: mockTxInsertReturning }) - mockTxInsert.mockReturnValue({ values: mockTxInsertValues }) - mockTxInsertReturning.mockResolvedValue([ - { id: "new-bc-id", name: "Summer Sale (Resend)" }, - ]) - mockDbTransaction.mockImplementation( - async (fn: (tx: { insert: typeof mockTxInsert }) => Promise) => - fn({ insert: mockTxInsert }), - ) - mockCreateId.mockReturnValue("new-bc-id") + mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({ + targetWorkspaceMember: { permissions: ["emailAndPhone"] }, + }) + mockFindContactFilter.mockResolvedValue({ contactFilter: null }) }) - test("throws ChatbotXException when broadcast status is not 'sent'", async () => { - const draftBroadcast = { ...baseBroadcast, status: "scheduled" as const } - mockFindOrFail.mockResolvedValue(draftBroadcast) - - await expect( - resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), - ).rejects.toThrow("Broadcast is not sent") - }) + test("reads the source broadcast's contact filter and delegates to broadcastService.resend", async () => { + mockResend.mockResolvedValue({ id: "new-bc-id" }) + mockFindContactFilter.mockResolvedValue({ + contactFilter: { operator: "and", conditions: [] }, + }) - test("allows resending a 'failed' broadcast", async () => { - const failedBroadcast = { ...baseBroadcast, status: "failed" as const } - mockFindOrFail.mockResolvedValue(failedBroadcast) + const result = await resendBroadcast({ + workspaceId: WORKSPACE_ID, + id: BROADCAST_ID, + }) - await expect( - resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), - ).resolves.not.toThrow() + expect(mockFindContactFilter).toHaveBeenCalledWith({ + id: BROADCAST_ID, + workspaceId: WORKSPACE_ID, + }) + expect(mockResend).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + id: BROADCAST_ID, + contactFilter: { operator: "and", conditions: [] }, + }) + expect(result).toEqual({ id: "new-bc-id" }) }) - test("throws ChatbotXException (not a generic Error) for non-sent status", async () => { - const draftBroadcast = { ...baseBroadcast, status: "draft" as const } - mockFindOrFail.mockResolvedValue(draftBroadcast) + test("propagates a 'Broadcast is not sent' error from the service", async () => { + mockResend.mockRejectedValue(new Error("Broadcast is not sent")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), - ).rejects.toBeInstanceOf(MockChatbotXException) + ).rejects.toThrow("Broadcast is not sent") }) - test("propagates error when findOrFail throws (broadcast not found)", async () => { - mockFindOrFail.mockRejectedValue(new Error("Record not found")) + test("propagates a not-found error when the source broadcast is missing", async () => { + mockResend.mockRejectedValue(new Error("Record not found")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), ).rejects.toThrow("Record not found") }) - test("inserts new broadcast with '(Resend)' suffix in name via transaction", async () => { - mockFindOrFail.mockResolvedValue(baseBroadcast) + test("passes undefined contactFilter when the source has none stored", async () => { + mockResend.mockResolvedValue({ id: "new-bc-id" }) + mockFindContactFilter.mockResolvedValue(undefined) await resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }) - expect(mockDbTransaction).toHaveBeenCalledOnce() - const insertedValues = mockTxInsertValues.mock.calls[0]?.[0] as { - name: string - status: string - } - expect(insertedValues.name).toBe("Summer Sale (Resend)") - expect(insertedValues.status).toBe("scheduled") - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "launch", - detail: "launched a broadcast (#new-bc-id)", - }) - }) - - test("new broadcast copies key fields from original", async () => { - mockFindOrFail.mockResolvedValue(baseBroadcast) - - await resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }) - - const insertedValues = mockTxInsertValues.mock.calls[0]?.[0] as { - workspaceId: string - flowId: string - channel: string - schedulesType: string - integrationWhatsappId: string - integrationMessengerId: string - } - expect(insertedValues.workspaceId).toBe(WORKSPACE_ID) - expect(insertedValues.flowId).toBe("flow-1") - expect(insertedValues.channel).toBe("whatsapp") - expect(insertedValues.schedulesType).toBe("now") - expect(insertedValues.integrationWhatsappId).toBe("wa-1") - expect(insertedValues.integrationMessengerId).toBe("msg-1") - }) - - test("new broadcast uses a new id from createId", async () => { - mockFindOrFail.mockResolvedValue(baseBroadcast) - mockCreateId.mockReturnValue("generated-id-42") - - await resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }) - - const insertedValues = mockTxInsertValues.mock.calls[0]?.[0] as { - id: string - } - expect(insertedValues.id).toBe("generated-id-42") - }) - - test("returns the new broadcast copy", async () => { - const newBroadcast = { id: "new-bc-id", name: "Summer Sale (Resend)" } - mockFindOrFail.mockResolvedValue(baseBroadcast) - mockTxInsertReturning.mockResolvedValue([newBroadcast]) - - const result = await resendBroadcast({ + expect(mockResend).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID, + contactFilter: undefined, }) - - expect(result).toBe(newBroadcast) - }) - - test("scopes findOrFail by workspaceId", async () => { - mockFindOrFail.mockResolvedValue(baseBroadcast) - - await resendBroadcast({ workspaceId: "other-ws", id: BROADCAST_ID }) - - const findArgs = mockFindOrFail.mock.calls[0]?.[0] as { - where: { workspaceId: string } - } - expect(findArgs.where.workspaceId).toBe("other-ws") }) }) diff --git a/apps/builder/__tests__/update-trigger-action.test.ts b/apps/builder/__tests__/update-trigger-action.test.ts index f3bc576232..5160cf6611 100644 --- a/apps/builder/__tests__/update-trigger-action.test.ts +++ b/apps/builder/__tests__/update-trigger-action.test.ts @@ -2,35 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const triggerModel = { - __model: "trigger", - id: "trigger.id", - workspaceId: "trigger.workspaceId", - } - const conditionModel = { __model: "condition", id: "condition.id" } - const triggerReturning = vi.fn() - const triggerWhere = vi.fn(() => ({ returning: triggerReturning })) - const triggerSet = vi.fn(() => ({ where: triggerWhere })) - const conditionWhere = vi.fn() - const conditionSet = vi.fn(() => ({ where: conditionWhere })) - const deleteWhere = vi.fn() - const insertValues = vi.fn() - - return { - auditRecord: vi.fn(), - conditionModel, - conditionSet, - createId: vi.fn(() => "condition-generated-id"), - dbTransaction: vi.fn(), - deleteWhere, - insertValues, - triggerModel, - triggerReturning, - triggerSet, - updateTriggerCache: vi.fn(), - } -}) +const { mockUpdateWithConditions } = vi.hoisted(() => ({ + mockUpdateWithConditions: vi.fn(), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -40,36 +14,10 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/audit", async (importOriginal) => { - const actual = - await importOriginal() - return { - ...actual, - auditService: { record: mocks.auditRecord }, - } -}) - -vi.mock("@chatbotx.io/database/client", () => ({ - and: (...args: unknown[]) => ({ and: args }), - db: { transaction: mocks.dbTransaction }, - eq: (...args: unknown[]) => ({ eq: args }), - inArray: (...args: unknown[]) => ({ inArray: args }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - conditionModel: mocks.conditionModel, - triggerModel: mocks.triggerModel, +vi.mock("@chatbotx.io/business", () => ({ + triggerService: { updateWithConditions: mockUpdateWithConditions }, })) -vi.mock("@chatbotx.io/events", () => ({ - updateTriggerCache: mocks.updateTriggerCache, -})) - -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: mocks.createId } -}) - vi.mock("@/features/conditions/to-condition-columns", () => ({ toConditionColumns: (condition: { type: string @@ -105,141 +53,64 @@ type Handler = (args: { parsedInput: { actions: unknown[]; conditions: Condition[] } }) => Promise -const tx = { - query: { - conditionModel: { findMany: vi.fn() }, - triggerModel: { findFirst: vi.fn() }, - }, - update: vi.fn((model: { __model: string }) => - model.__model === "trigger" - ? { set: mocks.triggerSet } - : { set: mocks.conditionSet }, - ), - delete: vi.fn(() => ({ where: mocks.deleteWhere })), - insert: vi.fn(() => ({ values: mocks.insertValues })), -} - -const existingCondition = { - id: "condition-1", - type: "contact", - sourceId: "email", - operator: "eq", - value: "ada@example.com", -} - const callAction = updateTriggerAction as unknown as Handler describe("updateTriggerAction", () => { beforeEach(() => { vi.clearAllMocks() - mocks.dbTransaction.mockImplementation( - async (fn: (txArg: typeof tx) => Promise) => fn(tx), - ) - mocks.triggerReturning.mockResolvedValue([{ id: "trigger-1" }]) - mocks.conditionSet.mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }) - mocks.deleteWhere.mockResolvedValue(undefined) - mocks.insertValues.mockResolvedValue(undefined) - tx.query.triggerModel.findFirst.mockResolvedValue({ - id: "trigger-1", - workspaceId: "workspace-1", - actions: [{ type: "startFlow", flowId: "flow-1" }], - }) - tx.query.conditionModel.findMany.mockResolvedValue([existingCondition]) - }) - - test("skips writes and audit but still invalidates cache for identical actions and conditions", async () => { - await callAction({ - bindArgsParsedInputs: ["workspace-1", "trigger-1"], - parsedInput: { - actions: [{ type: "startFlow", flowId: "flow-1" }], - conditions: [{ ...existingCondition }], - }, - }) - - expect(tx.update).not.toHaveBeenCalled() - expect(tx.delete).not.toHaveBeenCalled() - expect(tx.insert).not.toHaveBeenCalled() - // Cache invalidation must not be gated on the diff result — only the - // audit record should be. - expect(mocks.updateTriggerCache).toHaveBeenCalledWith("workspace-1") - expect(mocks.auditRecord).not.toHaveBeenCalled() + mockUpdateWithConditions.mockResolvedValue({ id: "trigger-1" }) }) - test("updates trigger actions and audits once for an actions-only change", async () => { - await callAction({ - bindArgsParsedInputs: ["workspace-1", "trigger-1"], - parsedInput: { - actions: [{ type: "addTags", tagIds: ["tag-1"] }], - conditions: [{ ...existingCondition }], - }, - }) - - expect(mocks.triggerSet).toHaveBeenCalledWith({ - actions: [{ type: "addTags", tagIds: ["tag-1"] }], - }) - expect(mocks.conditionSet).not.toHaveBeenCalled() - expect(mocks.auditRecord).toHaveBeenCalledTimes(1) - }) - - test("updates only changed existing conditions", async () => { - await callAction({ + test("maps conditions via toConditionColumns and delegates to triggerService.updateWithConditions", async () => { + const result = await callAction({ bindArgsParsedInputs: ["workspace-1", "trigger-1"], parsedInput: { actions: [{ type: "startFlow", flowId: "flow-1" }], conditions: [ - { ...existingCondition }, { id: "condition-1", type: "contact", sourceId: "email", - operator: "contains", - value: "example.com", + operator: "eq", + value: "ada@example.com", }, + { type: "contact", sourceId: "phone", operator: "exists" }, ], }, }) - expect(mocks.conditionSet).toHaveBeenCalledTimes(1) - expect(mocks.conditionSet).toHaveBeenCalledWith({ - type: "contact", - sourceId: "email", - operator: "contains", - value: "example.com", + expect(mockUpdateWithConditions).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "trigger-1", + actions: [{ type: "startFlow", flowId: "flow-1" }], + conditions: [ + { + id: "condition-1", + type: "contact", + sourceId: "email", + operator: "eq", + value: "ada@example.com", + }, + { + id: undefined, + type: "contact", + sourceId: "phone", + operator: "exists", + value: null, + }, + ], }) - expect(mocks.auditRecord).toHaveBeenCalledTimes(1) + expect(result).toEqual({ id: "trigger-1" }) }) - test("writes real create and delete while ignoring a no-op condition update", async () => { - tx.query.conditionModel.findMany.mockResolvedValue([ - existingCondition, - { ...existingCondition, id: "condition-delete" }, - ]) - - await callAction({ - bindArgsParsedInputs: ["workspace-1", "trigger-1"], - parsedInput: { - actions: [{ type: "startFlow", flowId: "flow-1" }], - conditions: [ - { ...existingCondition }, - { type: "contact", sourceId: "phone", operator: "exists" }, - ], - }, - }) + test("propagates errors from the service", async () => { + mockUpdateWithConditions.mockRejectedValue(new Error("boom")) - expect(mocks.conditionSet).not.toHaveBeenCalled() - expect(tx.delete).toHaveBeenCalledOnce() - expect(mocks.insertValues).toHaveBeenCalledWith([ - { - id: "condition-generated-id", - triggerId: "trigger-1", - type: "contact", - sourceId: "phone", - operator: "exists", - value: null, - }, - ]) - expect(mocks.auditRecord).toHaveBeenCalledTimes(1) + await expect( + callAction({ + bindArgsParsedInputs: ["workspace-1", "trigger-1"], + parsedInput: { actions: [], conditions: [] }, + }), + ).rejects.toThrow("boom") }) }) diff --git a/apps/builder/__tests__/update-trigger-settings-action.test.ts b/apps/builder/__tests__/update-trigger-settings-action.test.ts index aaab14e342..eb3f7d7122 100644 --- a/apps/builder/__tests__/update-trigger-settings-action.test.ts +++ b/apps/builder/__tests__/update-trigger-settings-action.test.ts @@ -2,20 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const updateReturning = vi.fn() - const updateWhere = vi.fn(() => ({ returning: updateReturning })) - const updateSet = vi.fn(() => ({ where: updateWhere })) - const dbUpdate = vi.fn(() => ({ set: updateSet })) - - return { - auditRecord: vi.fn(), - dbUpdate, - findFirst: vi.fn(), - updateReturning, - updateSet, - } -}) +const { mockUpdateSettings } = vi.hoisted(() => ({ + mockUpdateSettings: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -25,80 +14,48 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mocks.auditRecord }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { triggerModel: { findFirst: mocks.findFirst } }, - update: mocks.dbUpdate, - }, - eq: (...args: unknown[]) => ({ eq: args }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - triggerModel: { id: "trigger.id" }, +vi.mock("@chatbotx.io/business", () => ({ + triggerService: { updateSettings: mockUpdateSettings }, })) -const { updateTriggerSettings } = await import( +const { updateTriggerSettingsAction } = await import( "../src/features/triggers/actions/update-trigger-settings-action" ) -describe("updateTriggerSettings", () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.findFirst.mockResolvedValue({ - id: "trigger-1", - workspaceId: "workspace-1", - name: "Cart abandoned", - active: true, - }) - mocks.updateReturning.mockResolvedValue([{ id: "trigger-1" }]) - }) +type Handler = (args: { + bindArgsParsedInputs: [string, string] + parsedInput: { name?: string; active?: boolean } +}) => Promise - test("skips update and audit when active is unchanged", async () => { - await updateTriggerSettings( - { workspaceId: "workspace-1", id: "trigger-1" }, - { active: true }, - ) +const callAction = updateTriggerSettingsAction as unknown as Handler - expect(mocks.dbUpdate).not.toHaveBeenCalled() - expect(mocks.auditRecord).not.toHaveBeenCalled() +describe("updateTriggerSettingsAction", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateSettings.mockResolvedValue(undefined) }) - test("records enabled detail for a real active toggle", async () => { - mocks.findFirst.mockResolvedValue({ - id: "trigger-1", - workspaceId: "workspace-1", - name: "Cart abandoned", - active: false, + test("delegates to triggerService.updateSettings with workspaceId, id, and the patch", async () => { + await callAction({ + bindArgsParsedInputs: ["workspace-1", "trigger-1"], + parsedInput: { active: true }, }) - await updateTriggerSettings( - { workspaceId: "workspace-1", id: "trigger-1" }, - { active: true }, - ) - - expect(mocks.auditRecord).toHaveBeenCalledWith({ + expect(mockUpdateSettings).toHaveBeenCalledWith({ workspaceId: "workspace-1", - action: "update", - detail: "enabled a trigger (#trigger-1)", + id: "trigger-1", + active: true, }) }) - test("records generic update detail for a settings update", async () => { - await updateTriggerSettings( - { workspaceId: "workspace-1", id: "trigger-1" }, - { name: "New name" }, - ) + test("propagates a not-found error from the service", async () => { + mockUpdateSettings.mockRejectedValue(new Error("Trigger not found")) - expect(mocks.updateSet).toHaveBeenCalledWith({ name: "New name" }) - expect(mocks.updateReturning).toHaveBeenCalledWith({ id: "trigger.id" }) - expect(mocks.auditRecord).toHaveBeenCalledWith({ - workspaceId: "workspace-1", - action: "update", - detail: "updated a trigger (#trigger-1)", - }) + await expect( + callAction({ + bindArgsParsedInputs: ["workspace-1", "trigger-1"], + parsedInput: { name: "New name" }, + }), + ).rejects.toThrow("Trigger not found") }) }) diff --git a/apps/builder/__tests__/update-webhook-action.test.ts b/apps/builder/__tests__/update-webhook-action.test.ts index bba5f32b45..f836ec8136 100644 --- a/apps/builder/__tests__/update-webhook-action.test.ts +++ b/apps/builder/__tests__/update-webhook-action.test.ts @@ -2,12 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { mockDbTransaction, mockUpdateWebhookCache, mockRecordAuditLog } = - vi.hoisted(() => ({ - mockDbTransaction: vi.fn(), - mockUpdateWebhookCache: vi.fn().mockResolvedValue(undefined), - mockRecordAuditLog: vi.fn(), - })) +const { mockUpdateWithConditions } = vi.hoisted(() => ({ + mockUpdateWithConditions: vi.fn(), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -17,33 +14,22 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { transaction: mockDbTransaction }, - and: (...args: unknown[]) => ({ __and: args }), - eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), - inArray: (a: unknown, b: unknown) => ({ __inArray: [a, b] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - webhookModel: { id: "id", workspaceId: "workspaceId" }, - conditionModel: { id: "id" }, +vi.mock("@chatbotx.io/business", () => ({ + webhookService: { updateWithConditions: mockUpdateWithConditions }, })) -vi.mock("@chatbotx.io/events", () => ({ - updateWebhookCache: mockUpdateWebhookCache, -})) - -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, createId: () => "generated-id" } -}) - vi.mock("@/features/conditions/to-condition-columns", () => ({ - toConditionColumns: (c: unknown) => c, + toConditionColumns: (c: { + type: string + sourceId?: string | null + operator?: string | null + value?: unknown + }) => ({ + type: c.type, + sourceId: c.sourceId ?? null, + operator: c.operator ?? null, + value: c.value ?? null, + }), })) vi.mock("../src/features/webhooks/schema/update-webhook-schema", () => ({ @@ -54,68 +40,74 @@ const { updateWebhookAction } = await import( "../src/features/webhooks/actions/update-webhook-action" ) +type Condition = { + id?: string + type: string + sourceId?: string | null + operator?: string | null + value?: unknown +} + type Handler = (args: { bindArgsParsedInputs: [string, string] - parsedInput: { url: string; conditions: unknown[] } + parsedInput: { url: string; conditions: Condition[] } }) => Promise -const tx = { - query: { - conditionModel: { findMany: vi.fn().mockResolvedValue([]) }, - webhookModel: { - findFirst: vi - .fn() - .mockResolvedValue({ id: "webhook-1", name: "New Order" }), - }, - }, - update: vi.fn().mockReturnValue({ - set: vi - .fn() - .mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), - }), - delete: vi - .fn() - .mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), - insert: vi - .fn() - .mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), -} +const callAction = updateWebhookAction as unknown as Handler beforeEach(() => { vi.clearAllMocks() - mockDbTransaction.mockImplementation( - async (fn: (tx: unknown) => Promise) => fn(tx), - ) - tx.query.conditionModel.findMany.mockResolvedValue([]) - tx.query.webhookModel.findFirst.mockResolvedValue({ + mockUpdateWithConditions.mockResolvedValue({ id: "webhook-1", name: "New Order", }) }) describe("updateWebhookAction", () => { - test("emits an update audit row with the webhook name and id", async () => { - const result = await (updateWebhookAction as unknown as Handler)({ + test("maps conditions via toConditionColumns and delegates to webhookService.updateWithConditions", async () => { + const result = await callAction({ bindArgsParsedInputs: ["ws-1", "webhook-1"], - parsedInput: { url: "https://example.com/hook", conditions: [] }, + parsedInput: { + url: "https://example.com/hook", + conditions: [ + { id: "cond-1", type: "newContact" }, + { type: "tagApplied", sourceId: "tag-1" }, + ], + }, }) - expect(result).toEqual({ id: "webhook-1", name: "New Order" }) - expect(mockRecordAuditLog).toHaveBeenCalledWith({ + expect(mockUpdateWithConditions).toHaveBeenCalledWith({ workspaceId: "ws-1", - action: "update", - detail: "updated a webhook (#webhook-1)", + id: "webhook-1", + url: "https://example.com/hook", + conditions: [ + { + id: "cond-1", + type: "newContact", + sourceId: null, + operator: null, + value: null, + }, + { + id: undefined, + type: "tagApplied", + sourceId: "tag-1", + operator: null, + value: null, + }, + ], }) + expect(result).toEqual({ id: "webhook-1", name: "New Order" }) }) - test("does not emit when the webhook row is gone after the transaction", async () => { - tx.query.webhookModel.findFirst.mockResolvedValue(undefined) + test("returns undefined when the service reports no webhook", async () => { + mockUpdateWithConditions.mockResolvedValue(undefined) - await (updateWebhookAction as unknown as Handler)({ + const result = await callAction({ bindArgsParsedInputs: ["ws-1", "webhook-1"], parsedInput: { url: "https://example.com/hook", conditions: [] }, }) - expect(mockRecordAuditLog).not.toHaveBeenCalled() + expect(result).toBeUndefined() }) }) diff --git a/apps/builder/__tests__/update-webhook-settings-action.test.ts b/apps/builder/__tests__/update-webhook-settings-action.test.ts index 7d9ba9deb6..c275667890 100644 --- a/apps/builder/__tests__/update-webhook-settings-action.test.ts +++ b/apps/builder/__tests__/update-webhook-settings-action.test.ts @@ -2,31 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { - mockFindFirst, - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockUpdateWebhookCache, - mockRecordAuditLog, -} = vi.hoisted(() => { - const mockUpdateReturning = vi.fn().mockResolvedValue([{ id: "webhook-1" }]) - const mockUpdateWhere = vi.fn().mockReturnValue({ - returning: mockUpdateReturning, - }) - const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) - return { - mockFindFirst: vi.fn(), - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockUpdateWebhookCache: vi.fn().mockResolvedValue(undefined), - mockRecordAuditLog: vi.fn(), - } -}) +const { mockUpdateSettings } = vi.hoisted(() => ({ + mockUpdateSettings: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -36,24 +14,8 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { webhookModel: { findFirst: mockFindFirst } }, - update: mockUpdate, - }, - eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - webhookModel: { id: "id" }, -})) - -vi.mock("@chatbotx.io/events", () => ({ - updateWebhookCache: mockUpdateWebhookCache, +vi.mock("@chatbotx.io/business", () => ({ + webhookService: { updateSettings: mockUpdateSettings }, })) vi.mock("../src/features/webhooks/schema/update-webhook-schema", () => ({ @@ -69,102 +31,35 @@ type Handler = (args: { parsedInput: { active?: boolean; name?: string } }) => Promise +const callAction = updateWebhookSettingsAction as unknown as Handler + beforeEach(() => { vi.clearAllMocks() - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) - mockUpdateReturning.mockResolvedValue([{ id: "webhook-1" }]) - mockFindFirst.mockResolvedValue({ - id: "webhook-1", - name: "New Order", - active: false, - }) + mockUpdateSettings.mockResolvedValue(undefined) }) describe("updateWebhookSettingsAction", () => { - test("skips update, cache, and audit when active is unchanged", async () => { - await (updateWebhookSettingsAction as unknown as Handler)({ - bindArgsParsedInputs: ["ws-1", "webhook-1"], - parsedInput: { active: false }, - }) - - expect(mockUpdate).not.toHaveBeenCalled() - expect(mockUpdateWebhookCache).not.toHaveBeenCalled() - expect(mockRecordAuditLog).not.toHaveBeenCalled() - }) - - test("emits an 'enabled' detail when active flips to true", async () => { - await (updateWebhookSettingsAction as unknown as Handler)({ + test("delegates to webhookService.updateSettings with workspaceId, id, and the patch", async () => { + await callAction({ bindArgsParsedInputs: ["ws-1", "webhook-1"], parsedInput: { active: true }, }) - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: "ws-1", - action: "update", - detail: "enabled a webhook (#webhook-1)", - }) - }) - - test("emits a generic update detail when the name changes", async () => { - await (updateWebhookSettingsAction as unknown as Handler)({ - bindArgsParsedInputs: ["ws-1", "webhook-1"], - parsedInput: { name: "Orders" }, - }) - - expect(mockUpdateSet).toHaveBeenCalledWith({ name: "Orders" }) - expect(mockUpdateReturning).toHaveBeenCalledWith({ id: "id" }) - expect(mockUpdateWebhookCache).toHaveBeenCalledWith("ws-1") - expect(mockRecordAuditLog).toHaveBeenCalledWith({ + expect(mockUpdateSettings).toHaveBeenCalledWith({ workspaceId: "ws-1", - action: "update", - detail: "updated a webhook (#webhook-1)", - }) - }) - - test("skips cache and audit when the update races a concurrent delete", async () => { - mockUpdateReturning.mockResolvedValue([]) - - await (updateWebhookSettingsAction as unknown as Handler)({ - bindArgsParsedInputs: ["ws-1", "webhook-1"], - parsedInput: { active: true }, - }) - - expect(mockUpdate).toHaveBeenCalled() - expect(mockUpdateWebhookCache).not.toHaveBeenCalled() - expect(mockRecordAuditLog).not.toHaveBeenCalled() - }) - - test("emits a 'disabled' detail when active flips to false", async () => { - mockFindFirst.mockResolvedValue({ id: "webhook-1", - name: "New Order", active: true, }) - - await (updateWebhookSettingsAction as unknown as Handler)({ - bindArgsParsedInputs: ["ws-1", "webhook-1"], - parsedInput: { active: false }, - }) - - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: "ws-1", - action: "update", - detail: "disabled a webhook (#webhook-1)", - }) }) - test("throws when the webhook is not found", async () => { - mockFindFirst.mockResolvedValue(undefined) + test("propagates a not-found error from the service", async () => { + mockUpdateSettings.mockRejectedValue(new Error("Webhook not found")) await expect( - (updateWebhookSettingsAction as unknown as Handler)({ + callAction({ bindArgsParsedInputs: ["ws-1", "missing"], parsedInput: { active: true }, }), ).rejects.toThrow("Webhook not found") - - expect(mockRecordAuditLog).not.toHaveBeenCalled() }) }) diff --git a/apps/builder/__tests__/upsert-sequence-step.action.test.ts b/apps/builder/__tests__/upsert-sequence-step.action.test.ts index 9ee6c81b47..5c32230e6e 100644 --- a/apps/builder/__tests__/upsert-sequence-step.action.test.ts +++ b/apps/builder/__tests__/upsert-sequence-step.action.test.ts @@ -3,49 +3,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockFindFirst, - mockInsertReturning, - mockInsertValues, - mockInsert, - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockFindOrFail, - mockCreateId, + mockAssertOwned, + mockCreateStep, + mockUpdateStep, mockHandleStepCreationImpact, mockHandleStepUpdateImpact, -} = vi.hoisted(() => { - const mockInsertReturning = vi.fn().mockResolvedValue([{ id: "new-step-id" }]) - const mockInsertValues = vi - .fn() - .mockReturnValue({ returning: mockInsertReturning }) - const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) - - const mockUpdateReturning = vi.fn().mockResolvedValue([{ id: "step-1" }]) - const mockUpdateWhere = vi - .fn() - .mockReturnValue({ returning: mockUpdateReturning }) - const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) - - const mockFindFirst = vi.fn() - - return { - mockFindFirst, - mockInsertReturning, - mockInsertValues, - mockInsert, - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockFindOrFail: vi.fn().mockResolvedValue(undefined), - mockCreateId: vi.fn().mockReturnValue("new-step-id"), - mockHandleStepCreationImpact: vi.fn().mockResolvedValue(undefined), - mockHandleStepUpdateImpact: vi.fn().mockResolvedValue(undefined), - } -}) +} = vi.hoisted(() => ({ + mockAssertOwned: vi.fn().mockResolvedValue(undefined), + mockCreateStep: vi.fn(), + mockUpdateStep: vi.fn(), + mockHandleStepCreationImpact: vi.fn().mockResolvedValue(undefined), + mockHandleStepUpdateImpact: vi.fn().mockResolvedValue(undefined), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -55,31 +24,14 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - query: { - sequenceStepModel: { findFirst: mockFindFirst }, - }, - insert: mockInsert, - update: mockUpdate, +vi.mock("@chatbotx.io/business", () => ({ + sequenceService: { + assertOwned: mockAssertOwned, + createStep: mockCreateStep, + updateStep: mockUpdateStep, }, - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - findOrFail: mockFindOrFail, })) -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceModel: { id: "id", workspaceId: "workspaceId" }, - sequenceStepModel: { id: "id" }, -})) - -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createId: mockCreateId, - } -}) - vi.mock("@/features/common/schema", () => ({ workspaceIdrequestParams: [], })) @@ -97,7 +49,6 @@ const { upsertSequenceStepAction } = await import( "../src/features/sequences/actions/upsert-sequence-step.action" ) -// With the safe-action chain mock, the exported action IS the raw handler. type ActionHandler = (args: { bindArgsParsedInputs: [string] parsedInput: { @@ -109,11 +60,6 @@ type ActionHandler = (args: { delayUnit?: string flowId?: string isActive?: boolean - anytime?: boolean - sendTimeStart?: string | null - sendTimeEnd?: string | null - sendDays?: string[] - specificDateTime?: string } }) => Promise @@ -123,134 +69,50 @@ const WS = "ws-1" const SEQ_ID = "seq-1" const STEP_ID = "step-1" -/** Returns a minimal step whose parent sequence's workspaceId can be set. */ -const makeStep = (workspaceId = WS) => ({ - id: STEP_ID, - order: 1, - sequence: { workspaceId }, -}) - describe("upsertSequenceStepAction", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue(undefined) - mockFindFirst.mockResolvedValue(makeStep()) - mockInsert.mockReturnValue({ values: mockInsertValues }) - mockInsertValues.mockReturnValue({ returning: mockInsertReturning }) - mockInsertReturning.mockResolvedValue([{ id: "new-step-id" }]) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) - mockUpdateReturning.mockResolvedValue([{ id: STEP_ID }]) - mockCreateId.mockReturnValue("new-step-id") - mockHandleStepCreationImpact.mockResolvedValue(undefined) - mockHandleStepUpdateImpact.mockResolvedValue(undefined) + mockAssertOwned.mockResolvedValue(undefined) + mockCreateStep.mockResolvedValue({ id: "new-step-id" }) + mockUpdateStep.mockResolvedValue({ + previousOrder: 1, + step: { id: STEP_ID }, + }) }) - // ── CREATE PATH (no stepId) ────────────────────────────────────────────────── describe("create path (no stepId)", () => { - test("validates sequence ownership, inserts a new step, and returns stepId", async () => { - // Act + test("validates ownership, creates the step, and recalculates for affected contacts", async () => { const result = await callAction({ bindArgsParsedInputs: [WS], parsedInput: { sequenceId: SEQ_ID, order: 0 }, }) - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockInsert).toHaveBeenCalledTimes(1) - expect(mockInsertReturning).toHaveBeenCalledTimes(1) - expect(result).toEqual({ stepId: "new-step-id" }) - }) - - test("uses createId for the new step id", async () => { - // Arrange - mockCreateId.mockReturnValue("generated-id") - mockInsertReturning.mockResolvedValue([{ id: "generated-id" }]) - - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 1 }, + expect(mockAssertOwned).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, }) - - // Assert - expect(mockCreateId).toHaveBeenCalledTimes(1) - expect((result as { stepId: string }).stepId).toBe("generated-id") - }) - - test("inserts step with correct sequenceId and order", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 3 }, + expect(mockCreateStep).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, + data: { sequenceId: SEQ_ID, order: 0 }, }) - - // Assert - const insertArg = mockInsertValues.mock.calls[0]?.[0] as { - sequenceId: string - order: number - } - expect(insertArg.sequenceId).toBe(SEQ_ID) - expect(insertArg.order).toBe(3) - }) - - test("calls handleStepCreationImpact with sequenceId, workspaceId, and order", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 2 }, - }) - - // Assert - expect(mockHandleStepCreationImpact).toHaveBeenCalledWith(SEQ_ID, WS, 2) + expect(mockHandleStepCreationImpact).toHaveBeenCalledWith(SEQ_ID, WS, 0) expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() + expect(result).toEqual({ stepId: "new-step-id" }) }) - test("does not call db.query.findFirst (step lookup) on create path", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 0 }, - }) - - // Assert - expect(mockFindFirst).not.toHaveBeenCalled() - }) - - test("does not call db.update on create path", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 0 }, - }) - - // Assert - expect(mockUpdate).not.toHaveBeenCalled() - }) - - test("validates sequence ownership via findOrFail with workspace scope", async () => { - // Act + test("does not call updateStep on the create path", async () => { await callAction({ bindArgsParsedInputs: [WS], parsedInput: { sequenceId: SEQ_ID, order: 0 }, }) - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { id: string; workspaceId: string } - message: string - } - expect(args.where.id).toBe(SEQ_ID) - expect(args.where.workspaceId).toBe(WS) - expect(args.message).toBe("Sequence not found") + expect(mockUpdateStep).not.toHaveBeenCalled() }) }) - // ── UPDATE PATH (stepId provided) ─────────────────────────────────────────── describe("update path (stepId provided)", () => { - test("validates sequence ownership, updates the step, and returns stepId", async () => { - // Act + test("validates ownership, updates the step, and returns its id", async () => { const result = await callAction({ bindArgsParsedInputs: [WS], parsedInput: { @@ -261,16 +123,25 @@ describe("upsertSequenceStepAction", () => { }, }) - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockFindFirst).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdateReturning).toHaveBeenCalledTimes(1) + expect(mockUpdateStep).toHaveBeenCalledWith({ + workspaceId: WS, + stepId: STEP_ID, + data: { + stepId: STEP_ID, + sequenceId: SEQ_ID, + order: 1, + delayDays: 2, + }, + }) expect(result).toEqual({ stepId: STEP_ID }) }) test("calls handleStepUpdateImpact when delayDays changes", async () => { - // Act + mockUpdateStep.mockResolvedValue({ + previousOrder: 1, + step: { id: STEP_ID }, + }) + await callAction({ bindArgsParsedInputs: [WS], parsedInput: { @@ -281,7 +152,6 @@ describe("upsertSequenceStepAction", () => { }, }) - // Assert expect(mockHandleStepUpdateImpact).toHaveBeenCalledWith( SEQ_ID, WS, @@ -292,7 +162,11 @@ describe("upsertSequenceStepAction", () => { }) test("calls handleStepUpdateImpact when isActive changes", async () => { - // Act + mockUpdateStep.mockResolvedValue({ + previousOrder: 0, + step: { id: STEP_ID }, + }) + await callAction({ bindArgsParsedInputs: [WS], parsedInput: { @@ -303,59 +177,60 @@ describe("upsertSequenceStepAction", () => { }, }) - // Assert expect(mockHandleStepUpdateImpact).toHaveBeenCalledTimes(1) }) - test("does not call handleStepUpdateImpact when only flowId changes", async () => { + test("calls handleStepUpdateImpact when order changed from previousOrder", async () => { + mockUpdateStep.mockResolvedValue({ + previousOrder: 5, + step: { id: STEP_ID }, + }) + await callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 1, - flowId: "flow-abc", }, }) - expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() + expect(mockHandleStepUpdateImpact).toHaveBeenCalledTimes(1) }) - test("queries step with correct stepId and includes sequence relation", async () => { - // Act + test("does not call handleStepUpdateImpact when only flowId changes and order is unchanged", async () => { + mockUpdateStep.mockResolvedValue({ + previousOrder: 1, + step: { id: STEP_ID }, + }) + await callAction({ bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, + parsedInput: { + stepId: STEP_ID, + sequenceId: SEQ_ID, + order: 1, + flowId: "flow-abc", + }, }) - // Assert - const findArgs = mockFindFirst.mock.calls[0]?.[0] as { - where: { id: string } - with: { sequence: boolean } - } - expect(findArgs.where.id).toBe(STEP_ID) - expect(findArgs.with.sequence).toBe(true) + expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() }) - test("does not call db.insert on update path", async () => { - // Act + test("does not call createStep on the update path", async () => { await callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, }) - // Assert - expect(mockInsert).not.toHaveBeenCalled() + expect(mockCreateStep).not.toHaveBeenCalled() }) }) - // ── SEQUENCE NOT FOUND ─────────────────────────────────────────────────────── - describe("sequence not found", () => { - test("throws when findOrFail rejects on create path", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) + describe("errors", () => { + test("propagates a sequence-not-found error on the create path", async () => { + mockAssertOwned.mockRejectedValue(new Error("Sequence not found")) - // Act & Assert await expect( callAction({ bindArgsParsedInputs: [WS], @@ -364,29 +239,9 @@ describe("upsertSequenceStepAction", () => { ).rejects.toThrow("Sequence not found") }) - test("throws when findOrFail rejects on update path", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) + test("propagates a step-not-found error on the update path", async () => { + mockUpdateStep.mockRejectedValue(new Error("Step not found")) - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, - }), - ).rejects.toThrow("Sequence not found") - - expect(mockFindFirst).not.toHaveBeenCalled() - }) - }) - - // ── STEP NOT FOUND (update path) ───────────────────────────────────────────── - describe("step not found (update path)", () => { - test("throws 'Step not found' when db query returns null", async () => { - // Arrange - mockFindFirst.mockResolvedValue(null) - - // Act & Assert await expect( callAction({ bindArgsParsedInputs: [WS], @@ -394,26 +249,20 @@ describe("upsertSequenceStepAction", () => { }), ).rejects.toThrow("Step not found") - expect(mockUpdate).not.toHaveBeenCalled() + expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() }) - }) - // ── WORKSPACE MISMATCH (update path) ───────────────────────────────────────── - describe("workspace mismatch (update path)", () => { - test("throws unauthorized error when step belongs to a different workspace", async () => { - // Arrange - mockFindFirst.mockResolvedValue(makeStep("other-ws")) + test("propagates an unauthorized cross-workspace error on the update path", async () => { + mockUpdateStep.mockRejectedValue( + new Error("Unauthorized: Step does not belong to this workspace"), + ) - // Act & Assert await expect( callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, }), ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") - - expect(mockUpdate).not.toHaveBeenCalled() - expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() }) }) }) diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx index 0fc4f5cfaa..a970b95584 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx @@ -1,6 +1,6 @@ import { flowAnalyticsService } from "@chatbotx.io/analytics" import { smartDelayService } from "@chatbotx.io/business/smart-delay" -import { db } from "@chatbotx.io/database/client" +import { flowRepository } from "@chatbotx.io/database/repositories" import type { FlowNode } from "@chatbotx.io/flow-config" import { notFound } from "next/navigation" import type { FlowVersionResource } from "@/features/flow-versions/schema/resource" @@ -23,14 +23,9 @@ export default async function FlowAnalyticsPage({ await requireWorkspacePermission(data.workspaceId, "flows") - const flow = await db.query.flowModel.findFirst({ - where: { - id: data.id, - workspaceId: data.workspaceId, - }, - with: { - flowVersions: true, - }, + const flow = await flowRepository.findWithVersions({ + id: data.id, + workspaceId: data.workspaceId, }) if (!flow) { return notFound() diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx index d9f8646f63..ea0c102521 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx @@ -1,4 +1,4 @@ -import { db } from "@chatbotx.io/database/client" +import { flowRepository } from "@chatbotx.io/database/repositories" import { notFound } from "next/navigation" import { FlowDetail } from "@/features/flows/flow-detail" import { isSameContent } from "@/features/flows/flow-version-content" @@ -18,14 +18,9 @@ export default async function FlowPage({ params }: FlowPageProps) { await requireWorkspacePermission(data.workspaceId, "flows") - const flow = await db.query.flowModel.findFirst({ - where: { - id: data.id, - workspaceId: data.workspaceId, - }, - with: { - flowVersions: true, - }, + const flow = await flowRepository.findWithVersions({ + id: data.id, + workspaceId: data.workspaceId, }) if (!flow) { return notFound() diff --git a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts index 7b7d216422..f750026fb9 100644 --- a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts @@ -1,12 +1,6 @@ "use server" import { broadcastService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import { db } from "@chatbotx.io/database/client" -import { findBroadcastChannelCapability } from "@chatbotx.io/database/partials" -import { pruneEmailPhoneFilterConditions } from "@chatbotx.io/database/queries/contact-filter/permission" -import { broadcastModel } from "@chatbotx.io/database/schema" -import { startOfMinute } from "date-fns" import { returnValidationErrors } from "next-safe-action" import { workspaceIdrequestParams } from "@/features/common/schema" import { canViewContactEmailAndPhone } from "@/features/contacts/permissions" @@ -23,7 +17,6 @@ export const createBroadcastAction = workspaceActionClient parsedInput, } = props - let broadcastName = "Broadcast" const userAndWorkspace = await getCurrentUserAndTargetWorkspace(workspaceId) const canViewEmailAndPhone = userAndWorkspace ? canViewContactEmailAndPhone( @@ -31,163 +24,28 @@ export const createBroadcastAction = workspaceActionClient ) : false - const capability = findBroadcastChannelCapability(parsedInput.channel) - if (!capability) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - channel: { - _errors: ["Unsupported broadcast channel"], - }, - }) - } - - if (!capability.subactions.includes(parsedInput.subaction)) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - subaction: { - _errors: ["Unsupported broadcast subaction"], - }, - }) - } - - if (!(parsedInput.flowId || parsedInput.templateId)) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - flowId: { - _errors: ["Either flow or template must be selected"], - }, - }) - } - - if (parsedInput.templateId && !capability.supportsTemplateBroadcast) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - templateId: { - _errors: ["Template broadcasts are not supported for this channel"], - }, - }) - } - - // Never trust integration ids from the client: they scope the audience, - // so a foreign id would let a broadcast target another workspace's pages. - if (parsedInput.integrationMessengerId) { - const integration = await db.query.integrationMessengerModel.findFirst({ - where: { - id: parsedInput.integrationMessengerId, - workspaceId, - }, - columns: { id: true }, - }) - if (!integration) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - integrationMessengerId: { - _errors: ["Integration not found"], - }, - }) - } - } - - if (parsedInput.integrationWhatsappId) { - const integration = await db.query.integrationWhatsappModel.findFirst({ - where: { - id: parsedInput.integrationWhatsappId, - workspaceId, - }, - columns: { id: true }, - }) - if (!integration) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - integrationWhatsappId: { - _errors: ["Integration not found"], - }, - }) - } - } - - // Validate flow if flowId is provided - if (parsedInput.flowId) { - const flow = await db.query.flowModel.findFirst({ - where: { - workspaceId, - id: parsedInput.flowId, - }, - }) - if (!flow) { - return returnValidationErrors(createBroadcastRequest, { - _errors: ["Validation Exception"], - flowId: { - _errors: ["Flow not found"], - }, - }) - } - broadcastName = flow.name - } - - if (parsedInput.templateId) { - const templateBroadcastName = - await broadcastService.resolveTemplateBroadcastName({ - workspaceId, - channel: parsedInput.channel, - templateId: parsedInput.templateId, - integrationMessengerId: parsedInput.integrationMessengerId, - integrationWhatsappId: parsedInput.integrationWhatsappId, - }) - - if (!templateBroadcastName) { + try { + return await broadcastService.create({ + ...parsedInput, + workspaceId, + canViewEmailAndPhone, + }) + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" && + "field" in error + ) { + const field = error.field as string return returnValidationErrors(createBroadcastRequest, { _errors: ["Validation Exception"], - templateId: { - _errors: ["Template not found"], + [field]: { + _errors: [error.message], }, }) } - broadcastName = templateBroadcastName - } - - const { buttons, saveAsDraft, ...insertValues } = parsedInput - const contactFilter = pruneEmailPhoneFilterConditions( - insertValues.contactFilter, - canViewEmailAndPhone, - ) - - const [broadcast] = await db - .insert(broadcastModel) - .values({ - ...insertValues, - contactFilter, - name: broadcastName, - workspaceId, - status: saveAsDraft ? "draft" : "scheduled", - schedulesAt: startOfMinute( - new Date(parsedInput.schedulesAt ?? new Date()), - ), - templateData: parsedInput.templateData - ? { - ...(parsedInput.templateData as Record), - buttons: buttons ?? [], - } - : null, - }) - .returning() - - await auditService.record({ - workspaceId, - action: "create", - detail: `created a new broadcast (#${broadcast.id})`, - }) - - // A draft is never launched — it only leaves `draft` through - // `scheduleBroadcastAction`, which records its own `launch` entry. - if (parsedInput.schedulesType === "now" && !saveAsDraft) { - await auditService.record({ - workspaceId, - action: "launch", - detail: `launched a broadcast (#${broadcast.id})`, - }) + throw error } - - return broadcast }) diff --git a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts index 79056e0590..dfa01c3c14 100644 --- a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts @@ -1,11 +1,9 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db, findOrFail } from "@chatbotx.io/database/client" +import { broadcastService } from "@chatbotx.io/business" import { pruneEmailPhoneFilterConditions } from "@chatbotx.io/database/queries/contact-filter/permission" -import { broadcastModel } from "@chatbotx.io/database/schema" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { broadcastRepository } from "@chatbotx.io/database/repositories" +import { zodBigintAsString } from "@chatbotx.io/utils" import { contactFilterCriteriaSchema } from "@/features/contact-filter/schema" import { canViewContactEmailAndPhone } from "@/features/contacts/permissions" import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils" @@ -25,22 +23,17 @@ export const resendBroadcast = async (ctx: { workspaceId: string id: string }) => { - const broadcast = await findOrFail({ - table: broadcastModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - deletedAt: { isNull: true }, - }, - }) - if (broadcast.status !== "sent" && broadcast.status !== "failed") { - throw new ChatbotXException("Broadcast is not sent") - } const userAndWorkspace = await getCurrentUserAndTargetWorkspace( ctx.workspaceId, ) + + const broadcast = await broadcastRepository.findContactFilter({ + id: ctx.id, + workspaceId: ctx.workspaceId, + }) + const persistedContactFilter = contactFilterCriteriaSchema.safeParse( - broadcast.contactFilter, + broadcast?.contactFilter, ) const contactFilter = pruneEmailPhoneFilterConditions( persistedContactFilter.success ? persistedContactFilter.data : undefined, @@ -51,36 +44,9 @@ export const resendBroadcast = async (ctx: { : false, ) - const newBroadcast = await db.transaction(async (tx) => { - const newBroadcast = await tx - .insert(broadcastModel) - .values({ - workspaceId: ctx.workspaceId, - flowId: broadcast.flowId, - integrationWhatsappId: broadcast.integrationWhatsappId, - integrationMessengerId: broadcast.integrationMessengerId, - channel: broadcast.channel, - subaction: broadcast.subaction, - templateId: broadcast.templateId, - templateData: broadcast.templateData, - status: "scheduled", - schedulesType: "now", - schedulesAt: new Date(), - contactFilter, - name: `${broadcast.name} (Resend)`, - id: createId(), - }) - .returning() - .then((result) => result[0]) - - return newBroadcast - }) - - await auditService.record({ + return await broadcastService.resend({ workspaceId: ctx.workspaceId, - action: "launch", - detail: `launched a broadcast (#${newBroadcast.id})`, + id: ctx.id, + contactFilter, }) - - return newBroadcast } diff --git a/apps/builder/src/features/broadcasts/queries/index.ts b/apps/builder/src/features/broadcasts/queries/index.ts index c89aabfd6f..40b04d044c 100644 --- a/apps/builder/src/features/broadcasts/queries/index.ts +++ b/apps/builder/src/features/broadcasts/queries/index.ts @@ -1,14 +1,6 @@ import { notFoundException } from "@chatbotx.io/business/errors" -import { db, eq, relationsFilterToSQL } from "@chatbotx.io/database/client" -import { - broadcastModel, - contactsOnBroadcastsModel, -} from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, - parseOrderByAsObject, -} from "@chatbotx.io/database/utils" +import { broadcastRepository } from "@chatbotx.io/database/repositories" +import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import type { PaginatedResponse } from "@/features/common/schema/pagination" import type { GetBroadcastsSchema } from "../schema/query" import type { BroadcastResourceWithRelations } from "../schema/resource" @@ -16,43 +8,11 @@ import type { BroadcastResourceWithRelations } from "../schema/resource" export async function listBroadcasts( input: GetBroadcastsSchema, ): Promise> { - const where = { - workspaceId: input.workspaceId, - name: input.name ? { ilike: likeContains(input.name) } : undefined, - status: input.status ?? undefined, - deletedAt: { isNull: true as const }, - } - const pagination = getPaginationWithDefaults(input) - const orderBy = parseOrderByAsObject(broadcastModel, input) const [data, total] = await Promise.all([ - db.query.broadcastModel.findMany({ - where, - with: { - flow: { - columns: { - id: true, - name: true, - }, - }, - integrationWhatsapp: { - columns: { - id: true, - name: true, - }, - }, - integrationMessenger: { - columns: { - id: true, - name: true, - }, - }, - }, - ...pagination, - orderBy, - }), - db.$count(broadcastModel, relationsFilterToSQL(broadcastModel, where)), + broadcastRepository.listWithRelations(input), + broadcastRepository.count(input), ]) const pageCount = Math.ceil(total / pagination.limit) @@ -60,8 +20,6 @@ export async function listBroadcasts( return { data, pageCount } } -const NUMERIC_RE = /^\d+$/ - export async function listBroadcastAudience(input: { broadcastId: string workspaceId: string @@ -74,13 +32,9 @@ export async function listBroadcastAudience(input: { // findByIdForResponse/listExistingIds so a soft-deleted (or foreign) // broadcast never leaks its audience, even if a future caller skips the // publicGetBroadcast lookup the current API handler happens to run first. - const broadcast = await db.query.broadcastModel.findFirst({ - where: { - id: input.broadcastId, - workspaceId: input.workspaceId, - deletedAt: { isNull: true }, - }, - columns: { id: true }, + const broadcast = await broadcastRepository.findIdIfActive({ + id: input.broadcastId, + workspaceId: input.workspaceId, }) if (!broadcast) { @@ -88,16 +42,12 @@ export async function listBroadcastAudience(input: { } const [rows, total] = await Promise.all([ - db.query.contactsOnBroadcastsModel.findMany({ - where: { broadcastId: input.broadcastId }, - with: { contact: true }, + broadcastRepository.listAudience({ + broadcastId: input.broadcastId, limit, offset, }), - db.$count( - contactsOnBroadcastsModel, - eq(contactsOnBroadcastsModel.broadcastId, input.broadcastId), - ), + broadcastRepository.countAudience(input.broadcastId), ]) return { @@ -123,14 +73,10 @@ export async function publicGetBroadcast( workspaceId: string, idOrName: string, ) { - const where = { - ...(NUMERIC_RE.test(idOrName) - ? { id: idOrName, workspaceId } - : { name: idOrName, workspaceId }), - deletedAt: { isNull: true as const }, - } - - const broadcast = await db.query.broadcastModel.findFirst({ where }) + const broadcast = await broadcastRepository.findByIdOrName({ + workspaceId, + idOrName, + }) if (!broadcast) { throw notFoundException("Broadcast not found") diff --git a/apps/builder/src/features/flows/actions/publish-flow-action.ts b/apps/builder/src/features/flows/actions/publish-flow-action.ts index b52d3f05cb..1f89ff1987 100644 --- a/apps/builder/src/features/flows/actions/publish-flow-action.ts +++ b/apps/builder/src/features/flows/actions/publish-flow-action.ts @@ -1,13 +1,9 @@ "use server" import { flowVersionService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import { notFoundException } from "@chatbotx.io/business/errors" -import { and, db, eq } from "@chatbotx.io/database/client" -import { flowModel, flowVersionModel } from "@chatbotx.io/database/schema" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { type PublishFlowSchema, publishFlowSchema } from "../schema/action" +import { publishFlowSchema } from "../schema/action" export const publishFlowAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -18,80 +14,12 @@ export const publishFlowAction = workspaceActionClient parsedInput, } = props - await publishFlow({ workspaceId, id }, parsedInput) - }) - -export const publishFlow = async ( - ctx: { workspaceId: string; id: string }, - input: PublishFlowSchema, -) => { - const flow = await db.query.flowModel.findFirst({ - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - with: { - flowVersions: { - where: { - isDraft: true, - }, - }, - }, - }) - - if (!flow || flow.flowVersions.length === 0) { - throw notFoundException("Flow not found") - } - - const draftVersion = flow.flowVersions[0] - const validated = publishFlowSchema.parse(input) - - await db.transaction(async (tx) => { - // Remove all other latest versions - await tx - .update(flowVersionModel) - .set({ - isLatest: false, - }) - .where( - and( - eq(flowVersionModel.flowId, flow.id), - eq(flowVersionModel.isLatest, true), - ), - ) + const validated = publishFlowSchema.parse(parsedInput) - await tx - .update(flowVersionModel) - .set({ - nodes: validated.nodes, - edges: validated.edges, - }) - .where(eq(flowVersionModel.id, draftVersion.id)) - - const newVersionId = createId() - await tx.insert(flowVersionModel).values({ - id: newVersionId, - workspaceId: flow.workspaceId, - flowId: flow.id, - isDraft: false, - isLatest: true, - ...validated, - startNodeId: draftVersion.startNodeId, + await flowVersionService.publish({ + workspaceId, + flowId: id, + nodes: validated.nodes, + edges: validated.edges, }) - - await tx - .update(flowModel) - .set({ - currentVersionId: newVersionId, - }) - .where(eq(flowModel.id, flow.id)) - }) - - await flowVersionService.invalidateList(flow.id) - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "publish", - detail: `published a flow (#${flow.id})`, }) -} diff --git a/apps/builder/src/features/flows/queries/index.ts b/apps/builder/src/features/flows/queries/index.ts index 681f3b6285..b3301d4305 100644 --- a/apps/builder/src/features/flows/queries/index.ts +++ b/apps/builder/src/features/flows/queries/index.ts @@ -1,12 +1,10 @@ +import { flowService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" -import { db, relationsFilterToSQL } from "@chatbotx.io/database/client" -import { rootFolderId } from "@chatbotx.io/database/partials" -import { flowModel } from "@chatbotx.io/database/schema" import { - likeContains, - parseOrderByAsObject, - parsePagination, -} from "@chatbotx.io/database/utils" + flowRepository, + whatsappMessageTemplateRepository, +} from "@chatbotx.io/database/repositories" +import { parsePagination } from "@chatbotx.io/database/utils" import { stepTypes } from "@chatbotx.io/flow-config" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import { @@ -31,44 +29,11 @@ export const listFlowsRSC = async ( export async function listFlows( input: ListFlowsRequest & { workspaceId: string }, ): Promise { - const where = { - workspaceId: input.workspaceId, - folderId: input.folderId - ? // biome-ignore lint/style/noNestedTernary: allow nested ternary - input.folderId === rootFolderId - ? { isNull: true as const } - : input.folderId - : undefined, - name: input.name - ? { - ilike: likeContains(input.name), - } - : undefined, - active: input.active === null ? undefined : input.active, - } - const pagination = parsePagination(input) - const orderBy = parseOrderByAsObject(flowModel, input) let [data, total] = await Promise.all([ - db.query.flowModel.findMany({ - where, - orderBy, - ...pagination, - with: { - flowVersions: { - where: { - OR: [ - { isDraft: true }, - { - isLatest: true, - }, - ], - }, - }, - }, - }), - db.$count(flowModel, relationsFilterToSQL(flowModel, where)), + flowRepository.listWithVersions(input), + flowRepository.count(input), ]) if (input.startType) { @@ -76,11 +41,10 @@ export async function listFlows( if (input.startType === stepTypes.enum.sendWaTemplateMessage) { if (input.integrationWhatsappId) { - const templates = await db.query.whatsappMessageTemplateModel.findMany({ - where: { integrationWhatsappId: input.integrationWhatsappId }, - columns: { id: true }, - }) - const templateIds = templates.map((t) => t.id) + const templateIds = + await whatsappMessageTemplateRepository.listIdsByIntegration({ + integrationWhatsappId: input.integrationWhatsappId, + }) data = filterFlowsByTemplateIds(data, templateIds) } else { data = [] @@ -100,15 +64,7 @@ export const findFlow = async ( ): Promise<{ data: FlowResource | null }> => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const targetFlow = await db.query.flowModel.findFirst({ - where: { - workspaceId: input.workspaceId, - id: input.id, - }, - with: { - flowVersions: true, - }, - }) + const targetFlow = await flowRepository.findWithVersions(input) if (!targetFlow) { throw notFoundException("Flow does not exists.") } @@ -120,18 +76,5 @@ export const ensureAllFlowIdsExists = async ( workspaceId: string, flowIds: string[], ): Promise => { - const rows = await db.query.flowModel.findMany({ - where: { - workspaceId, - id: { - in: flowIds, - }, - }, - columns: { id: true }, - }) - const count = rows.length - - if (count !== flowIds.length) { - throw notFoundException("Flow does not exists.") - } + await flowService.assertAllExist({ workspaceId, flowIds }) } diff --git a/apps/builder/src/features/saved-replies/queries/index.ts b/apps/builder/src/features/saved-replies/queries/index.ts index 643d77608b..94e755c9fb 100644 --- a/apps/builder/src/features/saved-replies/queries/index.ts +++ b/apps/builder/src/features/saved-replies/queries/index.ts @@ -1,17 +1,10 @@ -import { db } from "@chatbotx.io/database/client" +import { savedReplyService } from "@chatbotx.io/business" import type { ListSavedReplyResponse } from "../schema/mutation" export async function listSavedReplies(input: { workspaceId: string }): Promise { - const data = await db.query.savedReplyModel.findMany({ - where: { - workspaceId: input.workspaceId, - }, - orderBy: { - createdAt: "asc", - }, - }) + const data = await savedReplyService.listByWorkspaceId(input.workspaceId) return { data } } diff --git a/apps/builder/src/features/sequences/actions/create-sequence.action.ts b/apps/builder/src/features/sequences/actions/create-sequence.action.ts index ed668989b9..f724485ed4 100644 --- a/apps/builder/src/features/sequences/actions/create-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/create-sequence.action.ts @@ -1,9 +1,6 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, isDatabaseError } from "@chatbotx.io/database/client" -import { sequenceModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" +import { sequenceService } from "@chatbotx.io/business" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -30,24 +27,17 @@ export const createSequenceAction = workspaceActionClient const t = await getTranslations() try { - const sequenceId = createId() - - await db.insert(sequenceModel).values({ - id: sequenceId, + return await sequenceService.create({ workspaceId, name: parsedInput.name, - folderId: parsedInput.folderId || null, + folderId: parsedInput.folderId, }) - - await auditService.record({ - workspaceId, - action: "create", - detail: `created a new sequence (#${sequenceId})`, - }) - - return { sequenceId } } catch (error) { - if (isDatabaseError(error) && error.cause.code === "23505") { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" + ) { return returnValidationErrors(createSequenceRequest, { _errors: [t("sequences.validation.exception")], name: { diff --git a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts index c7e03f6654..1476d30332 100644 --- a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts +++ b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts @@ -1,7 +1,6 @@ "use server" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { sequenceModel, sequenceStepModel } from "@chatbotx.io/database/schema" +import { sequenceService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" import { @@ -18,41 +17,6 @@ const deleteSequenceStepRequest = z.object({ type DeleteSequenceStepRequest = z.infer -async function validateSequenceOwnership( - sequenceId: string, - workspaceId: string, -) { - await findOrFail({ - table: sequenceModel, - where: { - id: sequenceId, - workspaceId, - }, - message: "Sequence not found", - }) -} - -async function deleteStep(stepId: string, workspaceId: string) { - const step = await db.query.sequenceStepModel.findFirst({ - where: { - id: stepId, - }, - with: { - sequence: true, - }, - }) - - if (!step) { - throw new Error("Step not found") - } - - if (step.sequence.workspaceId !== workspaceId) { - throw new Error("Unauthorized: Step does not belong to this workspace") - } - - await db.delete(sequenceStepModel).where(eq(sequenceStepModel.id, stepId)) -} - export const deleteSequenceStepAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(deleteSequenceStepRequest) @@ -66,8 +30,8 @@ export const deleteSequenceStepAction = workspaceActionClient }) => { const { stepId, sequenceId } = parsedInput - await validateSequenceOwnership(sequenceId, workspaceId) - await deleteStep(stepId, workspaceId) + await sequenceService.assertOwned({ workspaceId, sequenceId }) + await sequenceService.deleteStep({ workspaceId, stepId }) await recalculateAllContactsInSequence(sequenceId, workspaceId) return { success: true } diff --git a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts index 8e5cc66852..06147b277a 100644 --- a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts @@ -1,8 +1,6 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, findOrFail } from "@chatbotx.io/database/client" -import { sequenceModel } from "@chatbotx.io/database/schema" +import { sequenceService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" @@ -13,27 +11,5 @@ export const deleteSequenceAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - await deleteSequence({ workspaceId, id }) + await sequenceService.delete({ workspaceId, id }) }) - -export const deleteSequence = async (ctx: { - workspaceId: string - id: string -}) => { - const sequence = await findOrFail({ - table: sequenceModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Sequence not found", - }) - - await db.delete(sequenceModel).where(and(eq(sequenceModel.id, ctx.id))) - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "delete", - detail: `deleted a sequence (#${sequence.id})`, - }) -} diff --git a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts index c27ed23019..52a0b7f545 100644 --- a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts +++ b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts @@ -1,8 +1,6 @@ "use server" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { sequenceModel, sequenceStepModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" +import { sequenceService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, @@ -17,93 +15,6 @@ import { upsertSequenceStepRequest, } from "../schema/action" -async function validateSequenceOwnership( - sequenceId: string, - workspaceId: string, -) { - await findOrFail({ - table: sequenceModel, - where: { - id: sequenceId, - workspaceId, - }, - message: "Sequence not found", - }) -} - -function buildUpdateData( - parsedInput: UpsertSequenceStepRequest, -): Partial { - const { - order, - delayDays, - delayMinutes, - delayUnit, - flowId, - specificDateTime, - isActive, - anytime, - sendTimeStart, - sendTimeEnd, - sendDays, - } = parsedInput - - return { - order, - ...(delayDays !== undefined && { delayDays }), - ...(delayMinutes !== undefined && { delayMinutes }), - ...(delayUnit !== undefined && { delayUnit }), - ...(flowId !== undefined && { flowId }), - ...(specificDateTime !== undefined && { - specificDateTime: specificDateTime ? new Date(specificDateTime) : null, - }), - ...(isActive !== undefined && { isActive }), - ...(anytime !== undefined && { anytime }), - ...(sendTimeStart !== undefined && { - sendTimeStart: sendTimeStart || null, - }), - ...(sendTimeEnd !== undefined && { sendTimeEnd: sendTimeEnd || null }), - ...(sendDays !== undefined && { - sendDays: sendDays ? JSON.stringify(sendDays) : null, - }), - } -} - -function buildCreateData( - parsedInput: UpsertSequenceStepRequest, - sequenceId: string, -): typeof sequenceStepModel.$inferInsert { - const { - order, - delayDays, - delayMinutes, - delayUnit, - flowId, - specificDateTime, - isActive, - anytime, - sendTimeStart, - sendTimeEnd, - sendDays, - } = parsedInput - - return { - id: createId(), - sequenceId, - order, - delayDays: delayDays ?? 1, - delayMinutes: delayMinutes ?? 0, - delayUnit: delayUnit ?? "days", - flowId: flowId ?? null, - specificDateTime: specificDateTime ? new Date(specificDateTime) : null, - isActive: isActive ?? true, - anytime: anytime ?? true, - sendTimeStart: sendTimeStart || null, - sendTimeEnd: sendTimeEnd || null, - sendDays: sendDays ? JSON.stringify(sendDays) : null, - } -} - /** * Check if we need to recalculate contact schedules when UPDATING a step. * @@ -134,179 +45,6 @@ function shouldRecalculateOnUpdate( ) } -/** - * Handle step CREATION logic. - * - * FLOW: - * 1. Create new step in database - * 2. Recalculate schedules for affected contacts - * 3. Return stepId - * - * AFFECTED CONTACTS: - * Contacts with currentStep <= newStepOrder - * → They will reach this step in the future - * → nextRunAt needs recalculation to include new step's delay - * - * NOT AFFECTED: - * Contacts with currentStep > newStepOrder - * → Already passed this step, won't go back - * Completed contacts (status='completed') - * → Already finished sequence, no impact - * - * EXAMPLE: - * Sequence has steps: [0, 1, 2, 4, 5] - * Admin creates new step order=3 - * - * Contact A (currentStep=2): - * - Will reach new step 3 → nextRunAt needs update - * - * Contact B (currentStep=5): - * - Already passed step 3 → no impact - */ -async function handleStepCreation( - parsedInput: UpsertSequenceStepRequest, - sequenceId: string, - workspaceId: string, -): Promise<{ stepId: string }> { - const createData = buildCreateData(parsedInput, sequenceId) - const step = await createSequenceStep(createData) - - // Recalculate only for affected contacts (currentStep <= newStepOrder) - // More efficient than recalculating all contacts - await handleStepCreationImpact(sequenceId, workspaceId, parsedInput.order) - - return { stepId: step.id } -} - -/** - * Handle step UPDATE logic. - * - * FLOW: - * 1. Update step in database - * 2. Check if recalculation is needed (shouldRecalculateOnUpdate) - * 3. If needed: recalculate schedules for affected contacts - * 4. Return stepId - * - * WHEN TO RECALCULATE: - * Update delay (delayDays/delayMinutes/delayUnit) - * → Timing changes → contacts need new nextRunAt - * Update isActive (true ↔ false) - * → Step becomes available/unavailable → contacts skip or process - * Update order - * → Step position changes → timeline changes - * - * WHEN NOT TO RECALCULATE: - * Update flowId - * → Only changes message content, does not affect schedule - * Update sendTime/sendDays/anytime - * → Only affects worker dispatch logic, does not affect nextRunAt - * - * AFFECTED CONTACTS (when recalculating): - * GROUP 1: Contacts waiting for this step (nextStepId = stepId) - * → Update directly impacts them - * - * GROUP 2: Contacts at earlier steps (currentStep < stepOrder) - * → Will reach this step later → cumulative delay changes - * - * NOT AFFECTED: - * Contacts past this step (currentStep > stepOrder) - * → Already passed, won't go back - * Completed contacts (status='completed') - * → Already finished, no impact - * - * EXAMPLES: - * - * Example 1: Update delay of step 3 (1 day → 3 days) - * Contact A (currentStep=2, nextStepId=step3.id): - * → nextRunAt: tomorrow → 3 days later - * - * Example 2: Disable step 3 (isActive: true → false) - * Contact B (currentStep=2, nextStepId=step3.id): - * → nextStepId: step3.id → step4.id (next active) - * - * Example 3: Update flowId of step 3 - * Contact C (currentStep=2, nextStepId=step3.id): - * → No recalculate → nextRunAt unchanged, only message content changes - * - * Example 4: Update step 3, contact already at step 5 - * Contact D (currentStep=5): - * → Skip → already passed step 3 - */ -async function handleStepUpdate( - parsedInput: UpsertSequenceStepRequest, - stepId: string, - sequenceId: string, - workspaceId: string, -): Promise<{ stepId: string }> { - const updateData = buildUpdateData(parsedInput) - const { previousOrder, step } = await updateSequenceStep( - stepId, - updateData, - workspaceId, - ) - - // Only recalculate if changes affect scheduling - if (shouldRecalculateOnUpdate(parsedInput, previousOrder)) { - // Use targeted recalculation based on updated step - // Recalculate for: - // - GROUP 1: Contacts waiting for this step (nextStepId = stepId) - // - GROUP 2: Contacts at earlier steps (currentStep < stepOrder) - // NOT affected: - // - Contacts past this step (currentStep > stepOrder) - // - Completed contacts (status = 'completed') - await handleStepUpdateImpact( - sequenceId, - workspaceId, - stepId, - parsedInput.order, - ) - } - - return { stepId: step.id } -} - -async function updateSequenceStep( - stepId: string, - updateData: Partial, - workspaceId: string, -) { - const step = await db.query.sequenceStepModel.findFirst({ - where: { - id: stepId, - }, - with: { - sequence: true, - }, - }) - - if (!step) { - throw new Error("Step not found") - } - - if (step.sequence.workspaceId !== workspaceId) { - throw new Error("Unauthorized: Step does not belong to this workspace") - } - - const [updated] = await db - .update(sequenceStepModel) - .set(updateData) - .where(eq(sequenceStepModel.id, stepId)) - .returning() - - return { previousOrder: step.order, step: updated } -} - -async function createSequenceStep( - createData: typeof sequenceStepModel.$inferInsert, -) { - const [created] = await db - .insert(sequenceStepModel) - .values(createData) - .returning() - - return created -} - export const upsertSequenceStepAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(upsertSequenceStepRequest) @@ -320,21 +58,35 @@ export const upsertSequenceStepAction = workspaceActionClient }) => { const { stepId, sequenceId } = parsedInput - await validateSequenceOwnership(sequenceId, workspaceId) - - let result: { stepId: string } + await sequenceService.assertOwned({ workspaceId, sequenceId }) if (stepId) { - result = await handleStepUpdate( - parsedInput, - stepId, - sequenceId, + const { previousOrder, step } = await sequenceService.updateStep({ workspaceId, - ) - } else { - result = await handleStepCreation(parsedInput, sequenceId, workspaceId) + stepId, + data: parsedInput, + }) + + if (shouldRecalculateOnUpdate(parsedInput, previousOrder)) { + await handleStepUpdateImpact( + sequenceId, + workspaceId, + stepId, + parsedInput.order, + ) + } + + return { stepId: step.id } } - return result + const step = await sequenceService.createStep({ + workspaceId, + sequenceId, + data: parsedInput, + }) + + await handleStepCreationImpact(sequenceId, workspaceId, parsedInput.order) + + return { stepId: step.id } }, ) diff --git a/apps/builder/src/features/sequences/queries/index.ts b/apps/builder/src/features/sequences/queries/index.ts index c6acd6f445..6ed02fcc8a 100644 --- a/apps/builder/src/features/sequences/queries/index.ts +++ b/apps/builder/src/features/sequences/queries/index.ts @@ -1,15 +1,6 @@ -import { db, eq, relationsFilterToSQL } from "@chatbotx.io/database/client" -import { rootFolderId } from "@chatbotx.io/database/partials" -import { - contactsOnSequenceModel, - sequenceModel, - sequenceStepModel, -} from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, - parseOrderByAsObject, -} from "@chatbotx.io/database/utils" +import { notFoundException } from "@chatbotx.io/business/errors" +import { sequenceRepository } from "@chatbotx.io/database/repositories" +import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { ListSequencesRequest, @@ -19,50 +10,11 @@ import type { export async function listSequences( input: ListSequencesRequest, ): Promise { - let folderIdFilter: string | { isNull: true } | undefined - if (input.folderId) { - folderIdFilter = - input.folderId === rootFolderId - ? { isNull: true as const } - : input.folderId - } - - const where = { - workspaceId: input.workspaceId, - folderId: folderIdFilter, - name: input.name - ? { - ilike: likeContains(input.name), - } - : undefined, - active: - input.active !== undefined && input.active !== null - ? input.active - : undefined, - } - const pagination = getPaginationWithDefaults(input) - const orderBy = parseOrderByAsObject(sequenceModel, input) const [data, total] = await Promise.all([ - db.query.sequenceModel.findMany({ - where, - orderBy, - ...pagination, - extras: { - stepsCount: (table) => - db.$count( - sequenceStepModel, - eq(sequenceStepModel.sequenceId, table.id), - ), - subscribersCount: (table) => - db.$count( - contactsOnSequenceModel, - eq(contactsOnSequenceModel.sequenceId, table.id), - ), - }, - }), - db.$count(sequenceModel, relationsFilterToSQL(sequenceModel, where)), + sequenceRepository.listWithCounts(input), + sequenceRepository.count(input), ]) const pageCount = Math.ceil(total / pagination.limit) @@ -73,23 +25,13 @@ export async function listSequences( export async function getSequence(workspaceId: string, sequenceId: string) { await assertCurrentUserCanAccessChatbot(workspaceId) - const sequence = await db.query.sequenceModel.findFirst({ - where: { - id: sequenceId, - workspaceId, - }, - with: { - sequenceSteps: { - with: { - flow: true, - }, - orderBy: (step, { asc }) => [asc(step.order)], - }, - }, + const sequence = await sequenceRepository.findWithSteps({ + id: sequenceId, + workspaceId, }) if (!sequence) { - throw new Error("Sequence not found") + throw notFoundException("Sequence not found") } return { diff --git a/apps/builder/src/features/templates/queries/list-selectable-resources.ts b/apps/builder/src/features/templates/queries/list-selectable-resources.ts index 7cbaac0221..a7d12ba431 100644 --- a/apps/builder/src/features/templates/queries/list-selectable-resources.ts +++ b/apps/builder/src/features/templates/queries/list-selectable-resources.ts @@ -1,20 +1,5 @@ -import { db, relationsFilterToSQL } from "@chatbotx.io/database/client" import type { TemplateCategory } from "@chatbotx.io/database/partials" -import { - aiAgentModel, - aiFunctionModel, - appointmentCalendarModel, - automatedResponseModel, - customFieldModel, - fbCommentAutomationModel, - flowModel, - integrationWebchatModel, - productModel, - reflinkModel, - tagModel, - triggerModel, -} from "@chatbotx.io/database/schema" -import { likeContains } from "@chatbotx.io/database/utils" +import { templateSelectableResourceRepository } from "@chatbotx.io/database/repositories" const PAGE_SIZE = 100 const ALL_IDS_CAP = 1000 @@ -51,549 +36,112 @@ export const listSelectableResources = async (input: { const limit = input.limit ?? PAGE_SIZE const offset = input.cursor ? Number.parseInt(input.cursor, 10) || 0 : 0 + const categoryInput = { + workspaceId: input.workspaceId, + keyword: input.keyword, + offset, + limit, + } + switch (input.category) { case "flows": - return await listFlows(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listFlows(categoryInput), + offset, + limit, + ) case "tags": - return await listTags(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listTags(categoryInput), + offset, + limit, + ) case "customFields": - return await listCustomFields( - input.workspaceId, - input.keyword, + return projectRows( + await templateSelectableResourceRepository.listCustomFields( + categoryInput, + ), offset, limit, ) case "products": - return await listProducts(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listProducts(categoryInput), + offset, + limit, + ) case "aiFunctions": - return await listAIFunctions( - input.workspaceId, - input.keyword, + return projectRows( + await templateSelectableResourceRepository.listAIFunctions( + categoryInput, + ), offset, limit, ) case "aiAgents": - return await listAIAgents(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listAIAgents(categoryInput), + offset, + limit, + ) case "calendars": - return await listCalendars( - input.workspaceId, - input.keyword, + return projectRows( + await templateSelectableResourceRepository.listCalendars(categoryInput), offset, limit, ) case "webchats": - return await listWebchats(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listWebchats(categoryInput), + offset, + limit, + ) case "triggers": - return await listTriggers(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listTriggers(categoryInput), + offset, + limit, + ) case "fbCommentAutomations": - return await listFbCommentAutomations( - input.workspaceId, - input.keyword, + return projectRows( + await templateSelectableResourceRepository.listFbCommentAutomations( + categoryInput, + ), offset, limit, ) case "keywords": - return await listKeywords(input.workspaceId, input.keyword, offset, limit) + return projectRows( + await templateSelectableResourceRepository.listKeywords(categoryInput), + offset, + limit, + ) case "entryPointLinks": - return await listEntryPointLinks( - input.workspaceId, - input.keyword, + return projectRows( + await templateSelectableResourceRepository.listEntryPointLinks( + categoryInput, + ), offset, limit, ) case "settings": - return await listSettings(input.workspaceId, input.keyword, offset, limit) + return listSettings(input.workspaceId, input.keyword, offset, limit) default: return { items: [], nextCursor: null, total: 0 } } } -const buildAllIds = async ( - offset: number, - total: number, - findAllIds: () => Promise, -): Promise => - offset === 0 && total <= ALL_IDS_CAP ? await findAllIds() : undefined - -const listFlows = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.flowModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(flowModel, relationsFilterToSQL(flowModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - (await db.query.flowModel.findMany({ where, columns: { id: true } })).map( - (row) => row.id, - ), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listTags = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - deletedAt: { isNull: true as const }, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.tagModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(tagModel, relationsFilterToSQL(tagModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - (await db.query.tagModel.findMany({ where, columns: { id: true } })).map( - (row) => row.id, - ), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listCustomFields = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.customFieldModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(customFieldModel, relationsFilterToSQL(customFieldModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.customFieldModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listProducts = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.productModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(productModel, relationsFilterToSQL(productModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.productModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listAIFunctions = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.aiFunctionModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(aiFunctionModel, relationsFilterToSQL(aiFunctionModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.aiFunctionModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listAIAgents = async ( - workspaceId: string, - keyword: string | null | undefined, +const projectRows = ( + result: { rows: SelectableResourceItem[]; total: number; allIds?: string[] }, offset: number, limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.aiAgentModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(aiAgentModel, relationsFilterToSQL(aiAgentModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.aiAgentModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listCalendars = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - deletedAt: { isNull: true as const }, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.appointmentCalendarModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count( - appointmentCalendarModel, - relationsFilterToSQL(appointmentCalendarModel, where), - ), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.appointmentCalendarModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listWebchats = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.integrationWebchatModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count( - integrationWebchatModel, - relationsFilterToSQL(integrationWebchatModel, where), - ), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.integrationWebchatModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listTriggers = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.triggerModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(triggerModel, relationsFilterToSQL(triggerModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.triggerModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listFbCommentAutomations = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.fbCommentAutomationModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count( - fbCommentAutomationModel, - relationsFilterToSQL(fbCommentAutomationModel, where), - ), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.fbCommentAutomationModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -const listEntryPointLinks = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - db.query.reflinkModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - db.$count(reflinkModel, relationsFilterToSQL(reflinkModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.reflinkModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: row.name })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} - -/** - * `AutomatedResponse` (Keywords) has no `name` column — inbound rows are - * keyed by their `keywords` array and outbound rows by `text` — so the - * picker label falls back through `text`, then the joined keyword list. - * Search is done in the database on `keywords`/`text` directly rather than - * post-filtering in memory, so pagination stays exact under a search term. - */ -const listKeywords = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - // "keywords" is the inbound half of `AutomatedResponse` — the outbound - // half backs the unrelated "Page Automated Responses" comment-automation - // feature, which has no export category of its own. Without this filter, - // the picker would list a workspace's outbound rows under "Keywords" too. - const where = { - workspaceId, - type: "inbound" as const, - ...(keyword - ? { - OR: [ - { text: { ilike: likeContains(keyword) } }, - { keywords: { arrayContains: [keyword] } }, - ], - } - : {}), - } - - const [rows, total] = await Promise.all([ - db.query.automatedResponseModel.findMany({ - where, - columns: { id: true, text: true, keywords: true }, - limit, - offset, - orderBy: { createdAt: "desc" }, - }), - db.$count( - automatedResponseModel, - relationsFilterToSQL(automatedResponseModel, where), - ), - ]) - - const toLabel = (row: { text: string | null; keywords: string[] }): string => - row.text?.trim() || row.keywords.join(", ") || "(untitled)" - - const allIds = await buildAllIds(offset, total, async () => - ( - await db.query.automatedResponseModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { - items: rows.map((row) => ({ id: row.id, name: toLabel(row) })), - nextCursor: offset + rows.length < total ? String(offset + limit) : null, - total, - allIds, - } -} +): ListSelectableResourcesResult => ({ + items: result.rows, + nextCursor: + offset + result.rows.length < result.total ? String(offset + limit) : null, + total: result.total, + allIds: result.allIds, +}) /** * `settings` bundles two tables (`SavedReply`, `BotField`) under one @@ -608,16 +156,8 @@ const listSettings = async ( offset: number, limit: number, ): Promise => { - const [savedReplies, botFields] = await Promise.all([ - db.query.savedReplyModel.findMany({ - where: { workspaceId }, - columns: { id: true, shortcut: true }, - }), - db.query.botFieldModel.findMany({ - where: { workspaceId }, - columns: { id: true, name: true }, - }), - ]) + const { savedReplies, botFields } = + await templateSelectableResourceRepository.listSettings(workspaceId) const all = [ ...savedReplies.map((row) => ({ id: row.id, name: row.shortcut })), diff --git a/apps/builder/src/features/triggers/actions/update-trigger-action.ts b/apps/builder/src/features/triggers/actions/update-trigger-action.ts index 7e48282e1a..fd0f8ab0be 100644 --- a/apps/builder/src/features/triggers/actions/update-trigger-action.ts +++ b/apps/builder/src/features/triggers/actions/update-trigger-action.ts @@ -1,10 +1,7 @@ "use server" -import { auditService, isSameJsonValue } from "@chatbotx.io/business/audit" -import { and, db, eq, inArray } from "@chatbotx.io/database/client" -import { conditionModel, triggerModel } from "@chatbotx.io/database/schema" -import { updateTriggerCache } from "@chatbotx.io/events" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { triggerService } from "@chatbotx.io/business" +import { zodBigintAsString } from "@chatbotx.io/utils" import { toConditionColumns } from "@/features/conditions/to-condition-columns" import { workspaceActionClient } from "@/lib/safe-action" import { updateTriggerSchema } from "../schema/mutation" @@ -19,128 +16,13 @@ export const updateTriggerAction = workspaceActionClient } = props const { conditions, actions } = parsedInput - const result = await db.transaction(async (tx) => { - const [existingTrigger, existingConditions] = await Promise.all([ - tx.query.triggerModel.findFirst({ - where: { - id, - workspaceId, - }, - }), - tx.query.conditionModel.findMany({ - where: { - triggerId: id, - }, - }), - ]) - - if (!existingTrigger) { - return { trigger: undefined, hasRealChange: false } - } - - const existingIds = new Set(existingConditions.map((c) => c.id)) - const existingById = new Map(existingConditions.map((c) => [c.id, c])) - const submittedIds = new Set( - conditions.filter((c) => "id" in c && c.id).map((c) => c.id), - ) - - const conditionsToDelete = existingConditions.filter( - (existing) => !submittedIds.has(existing.id.toString()), - ) - - const conditionsToUpdate = conditions.filter( - (c) => "id" in c && c.id && existingIds.has(c.id), - ) - - const changedConditionsToUpdate = conditionsToUpdate.filter( - (condition) => { - const existing = condition.id - ? existingById.get(condition.id) - : undefined - if (!existing) { - return false - } - const next = toConditionColumns(condition) - return !isSameJsonValue(next, { - type: existing.type, - sourceId: existing.sourceId, - operator: existing.operator, - value: existing.value, - }) - }, - ) - - const conditionsToCreate = conditions.filter((c) => !("id" in c && c.id)) - - let actionsChanged = false - if (!isSameJsonValue(actions, existingTrigger.actions)) { - const updated = await tx - .update(triggerModel) - .set({ actions }) - .where( - and( - eq(triggerModel.workspaceId, workspaceId), - eq(triggerModel.id, id), - ), - ) - .returning({ id: triggerModel.id }) - - actionsChanged = updated.length > 0 - } - - if (conditionsToDelete.length > 0) { - await tx.delete(conditionModel).where( - inArray( - conditionModel.id, - conditionsToDelete.map((c) => c.id), - ), - ) - } - - for (const condition of changedConditionsToUpdate) { - await tx - .update(conditionModel) - .set(toConditionColumns(condition)) - .where(eq(conditionModel.id, condition.id ?? "")) - } - - if (conditionsToCreate.length > 0) { - await tx.insert(conditionModel).values( - conditionsToCreate.map((c) => ({ - id: createId(), - triggerId: id, - ...toConditionColumns(c), - })), - ) - } - - const trigger = await tx.query.triggerModel.findFirst({ - where: { - id, - }, - }) - - return { - trigger, - hasRealChange: - actionsChanged || - conditionsToDelete.length > 0 || - changedConditionsToUpdate.length > 0 || - conditionsToCreate.length > 0, - } + return await triggerService.updateWithConditions({ + workspaceId, + id, + actions, + conditions: conditions.map((condition) => ({ + id: "id" in condition ? condition.id : undefined, + ...toConditionColumns(condition), + })), }) - - if (result.trigger) { - await updateTriggerCache(workspaceId) - } - - if (result.hasRealChange) { - await auditService.record({ - workspaceId, - action: "update", - detail: `updated a trigger (#${id})`, - }) - } - - return result.trigger }) diff --git a/apps/builder/src/features/triggers/actions/update-trigger-settings-action.ts b/apps/builder/src/features/triggers/actions/update-trigger-settings-action.ts index 5d32f35708..e1e4ef1718 100644 --- a/apps/builder/src/features/triggers/actions/update-trigger-settings-action.ts +++ b/apps/builder/src/features/triggers/actions/update-trigger-settings-action.ts @@ -1,8 +1,6 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { triggerModel } from "@chatbotx.io/database/schema" +import { triggerService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" import { workspaceActionClient } from "@/lib/safe-action" @@ -12,8 +10,6 @@ const updateTriggerSettingsSchema = z.object({ active: z.optional(z.boolean()), }) -type UpdateTriggerSettingsSchema = z.infer - export const updateTriggerSettingsAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) .inputSchema(updateTriggerSettingsSchema) @@ -23,63 +19,9 @@ export const updateTriggerSettingsAction = workspaceActionClient parsedInput, } = props - return await updateTriggerSettings( - { - workspaceId, - id, - }, - parsedInput, - ) - }) - -export const updateTriggerSettings = async ( - ctx: { - workspaceId: string - id: string - }, - parsedInput: UpdateTriggerSettingsSchema, -) => { - const trigger = await db.query.triggerModel.findFirst({ - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - }) - - if (!trigger) { - throw new Error("Trigger not found") - } - - const changedEntries = Object.entries(parsedInput).filter( - ([key, value]) => - trigger[key as keyof UpdateTriggerSettingsSchema] !== value, - ) - - if (changedEntries.length === 0) { - return - } - - const updated = await db - .update(triggerModel) - .set(parsedInput) - .where(eq(triggerModel.id, trigger.id)) - .returning({ id: triggerModel.id }) - - if (updated.length === 0) { - return - } - - const changedKeys = changedEntries.map(([key]) => key) - let detail = `updated a trigger (#${trigger.id})` - if (changedKeys.length === 1 && changedKeys[0] === "active") { - detail = parsedInput.active - ? `enabled a trigger (#${trigger.id})` - : `disabled a trigger (#${trigger.id})` - } - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail, + await triggerService.updateSettings({ + workspaceId, + id, + ...parsedInput, + }) }) -} diff --git a/apps/builder/src/features/triggers/queries/index.ts b/apps/builder/src/features/triggers/queries/index.ts index 9c2b4e37a7..a8eff867b9 100644 --- a/apps/builder/src/features/triggers/queries/index.ts +++ b/apps/builder/src/features/triggers/queries/index.ts @@ -1,5 +1,7 @@ -import { and, count, db, eq, isNull } from "@chatbotx.io/database/client" -import { triggerModel } from "@chatbotx.io/database/schema" +import { + conditionRepository, + triggerRepository, +} from "@chatbotx.io/database/repositories" import type { TriggerModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { GetTriggersSchema, ListTriggersResponse } from "../schema/query" @@ -9,52 +11,22 @@ export async function getTriggers( ): Promise { await assertCurrentUserCanAccessChatbot(input.workspaceId) - // Build SQL conditions - const conditions = [eq(triggerModel.workspaceId, input.workspaceId)] - - if (input.folderId !== undefined) { - const folderId = - input.folderId === null || input.folderId === "" ? null : input.folderId - if (folderId === null) { - conditions.push(isNull(triggerModel.folderId)) - } else { - conditions.push(eq(triggerModel.folderId, folderId)) - } - } - - if (input.name) { - conditions.push(eq(triggerModel.name, input.name)) - } - - const whereClause = and(...conditions) - - // Execute queries - fetch triggers with SQL builder, then load conditions - const [triggers, countResult] = await Promise.all([ - db - .select() - .from(triggerModel) - .where(whereClause) - .limit(input.perPage) - .offset((input.page - 1) * input.perPage), - db.select({ count: count() }).from(triggerModel).where(whereClause), - ]) + const { rows: triggers, total } = await triggerRepository.listPaginated({ + workspaceId: input.workspaceId, + folderId: input.folderId, + name: input.name, + limit: input.perPage, + offset: (input.page - 1) * input.perPage, + }) - // Load conditions for triggers const triggerIds = triggers.map((t) => t.id) - const conditionsData = - triggerIds.length > 0 - ? await db.query.conditionModel.findMany({ - where: { triggerId: { in: triggerIds } }, - }) - : [] + const conditionsData = await conditionRepository.listByTriggerIds(triggerIds) - // Merge triggers with conditions const data = triggers.map((trigger) => ({ ...trigger, conditions: conditionsData.filter((c) => c.triggerId === trigger.id), })) - const total = countResult[0]?.count ?? 0 const pageCount = Math.ceil(total / input.perPage) return { data, pageCount } @@ -64,26 +36,9 @@ export async function findTrigger(params: { id?: string workspaceId?: string }): Promise { - const where: Record = {} - - if (params.id) { - where.id = params.id - } - - if (params.workspaceId) { - where.workspaceId = params.workspaceId - } - - if (Object.keys(where).length === 0) { + if (!(params.id || params.workspaceId)) { return null } - const result = await db.query.triggerModel.findFirst({ - where, - with: { - conditions: true, - }, - }) - - return result ?? null + return await triggerRepository.findWithConditions(params) } diff --git a/apps/builder/src/features/webhooks/actions/delete-webhooks-action.ts b/apps/builder/src/features/webhooks/actions/delete-webhooks-action.ts index 30871086ae..fab68cd9a8 100644 --- a/apps/builder/src/features/webhooks/actions/delete-webhooks-action.ts +++ b/apps/builder/src/features/webhooks/actions/delete-webhooks-action.ts @@ -1,9 +1,6 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, inArray } from "@chatbotx.io/database/client" -import { webhookModel } from "@chatbotx.io/database/schema" -import { removeWebhookCache } from "@chatbotx.io/events" +import { webhookService } from "@chatbotx.io/business" import { type BulkUpdateIdsRequest, bulkUpdateIdsRequest, @@ -23,28 +20,9 @@ export const deleteWebhooksAction = workspaceActionClient bindArgsParsedInputs: WorkspaceIdRequestParams parsedInput: BulkUpdateIdsRequest }) => { - const deletedWebhooks = await db.query.webhookModel.findMany({ - where: { workspaceId, id: { in: parsedInput.ids } }, - columns: { id: true }, + await webhookService.deleteMany({ + workspaceId, + ids: parsedInput.ids, }) - - await db - .delete(webhookModel) - .where( - and( - eq(webhookModel.workspaceId, workspaceId), - inArray(webhookModel.id, parsedInput.ids), - ), - ) - - await removeWebhookCache(workspaceId) - - if (deletedWebhooks.length > 0) { - await auditService.record({ - workspaceId, - action: "delete", - detail: `deleted webhook${deletedWebhooks.length > 1 ? "s" : ""} (${deletedWebhooks.map((webhook) => `#${webhook.id}`).join(", ")})`, - }) - } }, ) diff --git a/apps/builder/src/features/webhooks/actions/update-webhook-action.ts b/apps/builder/src/features/webhooks/actions/update-webhook-action.ts index 401b7f58f2..dd8ceedcad 100644 --- a/apps/builder/src/features/webhooks/actions/update-webhook-action.ts +++ b/apps/builder/src/features/webhooks/actions/update-webhook-action.ts @@ -1,10 +1,7 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { and, db, eq, inArray } from "@chatbotx.io/database/client" -import { conditionModel, webhookModel } from "@chatbotx.io/database/schema" -import { updateWebhookCache } from "@chatbotx.io/events" -import { createId, zodBigintAsString } from "@chatbotx.io/utils" +import { webhookService } from "@chatbotx.io/business" +import { zodBigintAsString } from "@chatbotx.io/utils" import { toConditionColumns } from "@/features/conditions/to-condition-columns" import { workspaceActionClient } from "@/lib/safe-action" import { updateWebhookRequest } from "../schema/update-webhook-schema" @@ -19,80 +16,13 @@ export const updateWebhookAction = workspaceActionClient } = props const { conditions, url } = parsedInput - const result = await db.transaction(async (tx) => { - const existingConditions = await tx.query.conditionModel.findMany({ - where: { - webhookId: id, - }, - }) - - const existingIds = new Set(existingConditions.map((c) => c.id)) - const submittedIds = new Set( - conditions.filter((c) => "id" in c && c.id).map((c) => c.id as string), - ) - - const conditionsToDelete = existingConditions.filter( - (existing) => !submittedIds.has(existing.id), - ) - - const conditionsToUpdate = conditions.filter( - (c) => "id" in c && c.id && existingIds.has(c.id as string), - ) - - const conditionsToCreate = conditions.filter((c) => !("id" in c && c.id)) - - await tx - .update(webhookModel) - .set({ url }) - .where( - and( - eq(webhookModel.workspaceId, workspaceId), - eq(webhookModel.id, id), - ), - ) - - if (conditionsToDelete.length > 0) { - await tx.delete(conditionModel).where( - inArray( - conditionModel.id, - conditionsToDelete.map((c) => c.id), - ), - ) - } - - for (const condition of conditionsToUpdate) { - await tx - .update(conditionModel) - .set(toConditionColumns(condition)) - .where(eq(conditionModel.id, condition.id as string)) - } - - if (conditionsToCreate.length > 0) { - await tx.insert(conditionModel).values( - conditionsToCreate.map((c) => ({ - id: createId(), - webhookId: id, - ...toConditionColumns(c), - })), - ) - } - - return await tx.query.webhookModel.findFirst({ - where: { - id, - }, - }) + return await webhookService.updateWithConditions({ + workspaceId, + id, + url, + conditions: conditions.map((condition) => ({ + id: "id" in condition ? condition.id : undefined, + ...toConditionColumns(condition), + })), }) - - await updateWebhookCache(workspaceId) - - if (result) { - await auditService.record({ - workspaceId, - action: "update", - detail: `updated a webhook (#${result.id})`, - }) - } - - return result }) diff --git a/apps/builder/src/features/webhooks/actions/update-webhook-settings-action.ts b/apps/builder/src/features/webhooks/actions/update-webhook-settings-action.ts index 00c28dfa7a..6fc8fe7056 100644 --- a/apps/builder/src/features/webhooks/actions/update-webhook-settings-action.ts +++ b/apps/builder/src/features/webhooks/actions/update-webhook-settings-action.ts @@ -1,15 +1,9 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq } from "@chatbotx.io/database/client" -import { webhookModel } from "@chatbotx.io/database/schema" -import { updateWebhookCache } from "@chatbotx.io/events" +import { webhookService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { - type UpdateWebhookSettingsRequest, - updateWebhookSettingsRequest, -} from "../schema/update-webhook-schema" +import { updateWebhookSettingsRequest } from "../schema/update-webhook-schema" export const updateWebhookSettingsAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -20,49 +14,9 @@ export const updateWebhookSettingsAction = workspaceActionClient parsedInput, } = props - const webhook = await db.query.webhookModel.findFirst({ - where: { - id, - workspaceId, - }, - }) - - if (!webhook) { - throw new Error("Webhook not found") - } - - const changedEntries = Object.entries(parsedInput).filter( - ([key, value]) => - webhook[key as keyof UpdateWebhookSettingsRequest] !== value, - ) - - if (changedEntries.length === 0) { - return - } - - const updated = await db - .update(webhookModel) - .set(parsedInput) - .where(eq(webhookModel.id, webhook.id)) - .returning({ id: webhookModel.id }) - - if (updated.length === 0) { - return - } - - await updateWebhookCache(workspaceId) - - const changedKeys = changedEntries.map(([key]) => key) - let detail = `updated a webhook (#${webhook.id})` - if (changedKeys.length === 1 && changedKeys[0] === "active") { - detail = parsedInput.active - ? `enabled a webhook (#${webhook.id})` - : `disabled a webhook (#${webhook.id})` - } - - await auditService.record({ + await webhookService.updateSettings({ workspaceId, - action: "update", - detail, + id, + ...parsedInput, }) }) diff --git a/apps/builder/src/features/webhooks/queries/index.ts b/apps/builder/src/features/webhooks/queries/index.ts index 9b502ccc02..aa27ea52ae 100644 --- a/apps/builder/src/features/webhooks/queries/index.ts +++ b/apps/builder/src/features/webhooks/queries/index.ts @@ -1,6 +1,8 @@ -import { and, count, db, eq, isNull } from "@chatbotx.io/database/client" -import { rootFolderId } from "@chatbotx.io/database/partials" -import { webhookModel } from "@chatbotx.io/database/schema" +import { + conditionRepository, + findWebhookWithConditions, + listWebhooksPaginated, +} from "@chatbotx.io/database/repositories" import type { WebhookModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { WebhookCollection } from "../schema" @@ -11,54 +13,22 @@ export async function getWebhooks( ): Promise { await assertCurrentUserCanAccessChatbot(input.workspaceId) - // Build SQL conditions - const conditions = [eq(webhookModel.workspaceId, input.workspaceId)] - - if (input.folderId !== undefined) { - const folderId = - input.folderId === null || input.folderId === rootFolderId - ? null - : input.folderId - if (folderId === null) { - conditions.push(isNull(webhookModel.folderId)) - } else { - conditions.push(eq(webhookModel.folderId, folderId)) - } - } - - if (input.name) { - conditions.push(eq(webhookModel.name, input.name)) - } - - const whereClause = and(...conditions) - - // Execute queries - fetch webhooks with SQL builder, then load conditions - const [webhooks, countResult] = await Promise.all([ - db - .select() - .from(webhookModel) - .where(whereClause) - .limit(input.perPage) - .offset((input.page - 1) * input.perPage), - db.select({ count: count() }).from(webhookModel).where(whereClause), - ]) + const { rows: webhooks, total } = await listWebhooksPaginated({ + workspaceId: input.workspaceId, + folderId: input.folderId, + name: input.name, + limit: input.perPage, + offset: (input.page - 1) * input.perPage, + }) - // Load conditions for webhooks const webhookIds = webhooks.map((w) => w.id) - const conditionsData = - webhookIds.length > 0 - ? await db.query.conditionModel.findMany({ - where: { webhookId: { in: webhookIds } }, - }) - : [] + const conditionsData = await conditionRepository.listByWebhookIds(webhookIds) - // Merge webhooks with conditions const data = webhooks.map((webhook) => ({ ...webhook, conditions: conditionsData.filter((c) => c.webhookId === webhook.id), })) - const total = countResult[0]?.count ?? 0 const pageCount = Math.ceil(total / input.perPage) return { data, pageCount } @@ -68,26 +38,9 @@ export async function findWebhook(params: { id?: string workspaceId?: string }): Promise { - const where: Record = {} - - if (params.id) { - where.id = params.id - } - - if (params.workspaceId) { - where.workspaceId = params.workspaceId - } - - if (Object.keys(where).length === 0) { + if (!(params.id || params.workspaceId)) { return null } - const result = await db.query.webhookModel.findFirst({ - where, - with: { - conditions: true, - }, - }) - - return result ?? null + return await findWebhookWithConditions(params) } diff --git a/packages/business/__tests__/broadcast-service-create.test.ts b/packages/business/__tests__/broadcast-service-create.test.ts new file mode 100644 index 0000000000..04f2a767b3 --- /dev/null +++ b/packages/business/__tests__/broadcast-service-create.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockFindCapability, + findFirstFlow, + findFirstIntegrationWhatsapp, + findFirstIntegrationMessenger, + insertValues, + insertReturning, + mockPruneFilter, + mockDispatchAuditRecord, +} = vi.hoisted(() => ({ + mockFindCapability: vi.fn(), + findFirstFlow: vi.fn(), + findFirstIntegrationWhatsapp: vi.fn(), + findFirstIntegrationMessenger: vi.fn(), + insertValues: vi.fn(), + insertReturning: vi.fn(), + mockPruneFilter: vi.fn((filter: unknown) => filter), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + flowModel: { findFirst: findFirstFlow }, + integrationWhatsappModel: { findFirst: findFirstIntegrationWhatsapp }, + integrationMessengerModel: { findFirst: findFirstIntegrationMessenger }, + }, + insert: () => ({ + values: (values: Record) => { + insertValues(values) + return { returning: () => insertReturning() } + }, + }), + }, + and: (...args: unknown[]) => ({ __and: args }), + asc: vi.fn(), + count: vi.fn(), + desc: vi.fn(), + eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), + findOrFail: vi.fn(), + gt: vi.fn(), + inArray: vi.fn(), + isNotNull: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + or: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn() }), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + broadcastStatuses: { enum: { draft: "draft", scheduled: "scheduled" } }, + findBroadcastChannelCapability: mockFindCapability, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: {}, + contactInboxModel: {}, + contactModel: {}, + contactsOnBroadcastsModel: {}, + conversationModel: {}, + integrationMessengerModel: {}, + integrationWhatsappModel: {}, + messengerMessageTemplateModel: {}, + whatsappMessageTemplateModel: {}, +})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactInboxContactFilterSQL: vi.fn(), + contactInboxInteractedWithin24hSQL: vi.fn(), + pruneEmailPhoneFilterConditions: mockPruneFilter, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + chunkById: vi.fn(), + likeContains: vi.fn(), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/inbox/service", () => ({ inboxService: {} })) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { broadcastService } = await import("../src/broadcast/service") + +const WS = "ws-1" + +const baseInput = { + workspaceId: WS, + canViewEmailAndPhone: true, + channel: "whatsapp" as const, + subaction: "sendMessage" as const, + schedulesType: "now" as const, + schedulesAt: null, + flowId: "flow-1", + saveAsDraft: false, +} + +describe("broadcastService.create — validation branches", () => { + beforeEach(() => { + vi.clearAllMocks() + mockPruneFilter.mockImplementation((filter: unknown) => filter) + insertReturning.mockResolvedValue([{ id: "broadcast-1" }]) + }) + + test("throws validationException(channel) for an unsupported channel", async () => { + mockFindCapability.mockReturnValue(undefined) + + await expect(broadcastService.create(baseInput)).rejects.toMatchObject({ + code: "validation", + field: "channel", + message: "Unsupported broadcast channel", + }) + }) + + test("throws validationException(subaction) for an unsupported subaction", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["other"], + supportsTemplateBroadcast: false, + }) + + await expect(broadcastService.create(baseInput)).rejects.toMatchObject({ + code: "validation", + field: "subaction", + message: "Unsupported broadcast subaction", + }) + }) + + test("throws validationException(flowId) when neither flow nor template is given", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + + await expect( + broadcastService.create({ ...baseInput, flowId: undefined }), + ).rejects.toMatchObject({ + code: "validation", + field: "flowId", + message: "Either flow or template must be selected", + }) + }) + + test("throws validationException(templateId) when the channel does not support template broadcasts", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + + await expect( + broadcastService.create({ + ...baseInput, + flowId: undefined, + templateId: "template-1", + }), + ).rejects.toMatchObject({ + code: "validation", + field: "templateId", + message: "Template broadcasts are not supported for this channel", + }) + }) + + test("throws validationException(integrationMessengerId) when the integration is not owned", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstIntegrationMessenger.mockResolvedValue(undefined) + + await expect( + broadcastService.create({ + ...baseInput, + integrationMessengerId: "integration-1", + }), + ).rejects.toMatchObject({ + code: "validation", + field: "integrationMessengerId", + message: "Integration not found", + }) + }) + + test("attributes the ownership error to integrationWhatsappId when both ids are supplied and only WhatsApp is not owned", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstIntegrationMessenger.mockResolvedValue({ id: "integration-1" }) + findFirstIntegrationWhatsapp.mockResolvedValue(undefined) + + await expect( + broadcastService.create({ + ...baseInput, + integrationMessengerId: "integration-1", + integrationWhatsappId: "integration-2", + }), + ).rejects.toMatchObject({ + code: "validation", + field: "integrationWhatsappId", + message: "Integration not found", + }) + }) + + test("throws validationException(flowId) when the flow does not belong to the workspace", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue(undefined) + + await expect(broadcastService.create(baseInput)).rejects.toMatchObject({ + code: "validation", + field: "flowId", + message: "Flow not found", + }) + }) + + test("creates the broadcast and audits create + launch when scheduled now", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + + const result = await broadcastService.create(baseInput) + + expect(result).toEqual({ id: "broadcast-1" }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "create", + detail: "created a new broadcast (#broadcast-1)", + }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "launch", + detail: "launched a broadcast (#broadcast-1)", + }) + }) + + test("does not launch-audit when saveAsDraft is true", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + + await broadcastService.create({ ...baseInput, saveAsDraft: true }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "create", + detail: "created a new broadcast (#broadcast-1)", + }) + expect(mockDispatchAuditRecord).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "launch" }), + ) + }) + + test("does not launch-audit when schedulesType is not 'now'", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + + await broadcastService.create({ + ...baseInput, + schedulesType: "scheduled" as never, + }) + + expect(mockDispatchAuditRecord).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "launch" }), + ) + }) +}) diff --git a/packages/business/__tests__/broadcast-service-resend.test.ts b/packages/business/__tests__/broadcast-service-resend.test.ts new file mode 100644 index 0000000000..475bb4cbdf --- /dev/null +++ b/packages/business/__tests__/broadcast-service-resend.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockFindOrFail, + mockTxInsert, + mockTxInsertValues, + mockTxInsertReturning, + mockDbTransaction, + mockCreateId, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockTxInsertReturning = vi.fn() + const mockTxInsertValues = vi + .fn() + .mockReturnValue({ returning: mockTxInsertReturning }) + const mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }) + + return { + mockFindOrFail: vi.fn(), + mockTxInsert, + mockTxInsertValues, + mockTxInsertReturning, + mockDbTransaction: vi.fn(), + mockCreateId: vi.fn(() => "new-broadcast-id"), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + transaction: mockDbTransaction, + }, + and: vi.fn(), + asc: vi.fn(), + count: vi.fn(), + desc: vi.fn(), + eq: vi.fn(), + findOrFail: mockFindOrFail, + gt: vi.fn(), + inArray: vi.fn(), + isNotNull: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + or: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn() }), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + broadcastStatuses: { enum: { draft: "draft", scheduled: "scheduled" } }, + findBroadcastChannelCapability: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: {}, + contactInboxModel: {}, + contactModel: {}, + contactsOnBroadcastsModel: {}, + conversationModel: {}, + integrationMessengerModel: {}, + integrationWhatsappModel: {}, + messengerMessageTemplateModel: {}, + whatsappMessageTemplateModel: {}, +})) + +vi.mock("@chatbotx.io/database/queries", () => ({ + buildContactInboxContactFilterSQL: vi.fn(), + contactInboxInteractedWithin24hSQL: vi.fn(), + pruneEmailPhoneFilterConditions: vi.fn((filter: unknown) => filter), +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + chunkById: vi.fn(), + likeContains: vi.fn(), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/inbox/service", () => ({ inboxService: {} })) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { broadcastService } = await import("../src/broadcast/service") + +const WS = "ws-1" +const SOURCE_ID = "broadcast-1" + +const sourceBroadcast = { + id: SOURCE_ID, + workspaceId: WS, + status: "sent", + flowId: "flow-1", + integrationWhatsappId: "wa-1", + integrationMessengerId: null, + channel: "whatsapp", + subaction: "sendMessage", + templateId: null, + templateData: null, + name: "My Broadcast", +} + +describe("broadcastService.resend", () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbTransaction.mockImplementation( + async (fn: (tx: { insert: typeof mockTxInsert }) => Promise) => + fn({ insert: mockTxInsert }), + ) + mockTxInsertReturning.mockResolvedValue([ + { id: "new-broadcast-id", name: "My Broadcast (Resend)" }, + ]) + }) + + test("throws when the source broadcast status is not sent or failed", async () => { + mockFindOrFail.mockResolvedValue({ ...sourceBroadcast, status: "draft" }) + + await expect( + broadcastService.resend({ workspaceId: WS, id: SOURCE_ID }), + ).rejects.toThrow("Broadcast is not sent") + + expect(mockDbTransaction).not.toHaveBeenCalled() + }) + + test("clones a 'sent' broadcast as a new scheduled-now broadcast, appending (Resend) to the name", async () => { + mockFindOrFail.mockResolvedValue(sourceBroadcast) + + const result = await broadcastService.resend({ + workspaceId: WS, + id: SOURCE_ID, + }) + + expect(result).toEqual({ + id: "new-broadcast-id", + name: "My Broadcast (Resend)", + }) + expect(mockTxInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WS, + flowId: "flow-1", + integrationWhatsappId: "wa-1", + integrationMessengerId: null, + channel: "whatsapp", + subaction: "sendMessage", + templateId: null, + templateData: null, + status: "scheduled", + schedulesType: "now", + name: "My Broadcast (Resend)", + id: "new-broadcast-id", + }), + ) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "launch", + detail: "launched a broadcast (#new-broadcast-id)", + }) + }) + + test("clones a 'failed' broadcast too", async () => { + mockFindOrFail.mockResolvedValue({ ...sourceBroadcast, status: "failed" }) + + await expect( + broadcastService.resend({ workspaceId: WS, id: SOURCE_ID }), + ).resolves.toEqual({ + id: "new-broadcast-id", + name: "My Broadcast (Resend)", + }) + }) +}) diff --git a/packages/business/__tests__/flow-import-flow-export.test.ts b/packages/business/__tests__/flow-import-flow-export.test.ts index d47d881262..5174e390fc 100644 --- a/packages/business/__tests__/flow-import-flow-export.test.ts +++ b/packages/business/__tests__/flow-import-flow-export.test.ts @@ -44,6 +44,10 @@ vi.mock("@chatbotx.io/database/partials", () => ({ rootFolderId: "0", })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + flowRepository: { listIdsByIds: vi.fn() }, +})) + vi.mock("@chatbotx.io/database/schema", () => ({ flowAnalyticsSessionModel, flowModel, @@ -52,6 +56,7 @@ vi.mock("@chatbotx.io/database/schema", () => ({ vi.mock("@chatbotx.io/flow-config", () => ({ remapFlowGraphReferences: mockRemapFlowGraphReferences, + sendMessageNodeDefaultFn: vi.fn(() => ({ id: "default-node" })), })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/__tests__/flow-version-service-publish.test.ts b/packages/business/__tests__/flow-version-service-publish.test.ts new file mode 100644 index 0000000000..8221673e61 --- /dev/null +++ b/packages/business/__tests__/flow-version-service-publish.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment node + +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateId, + mockDbTransaction, + mockFlowFindFirst, + mockTxInsert, + mockTxInsertValues, + mockTxUpdate, + mockTxSet, + mockInvalidateCacheTags, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockTxInsertValues = vi.fn().mockResolvedValue(undefined) + const mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }) + const mockTxWhere = vi.fn().mockResolvedValue(undefined) + const mockTxSet = vi.fn().mockReturnValue({ where: mockTxWhere }) + const mockTxUpdate = vi.fn().mockReturnValue({ set: mockTxSet }) + + return { + mockCreateId: vi.fn(), + mockDbTransaction: vi.fn(), + mockFlowFindFirst: vi.fn(), + mockTxInsert, + mockTxInsertValues, + mockTxUpdate, + mockTxSet, + mockInvalidateCacheTags: vi.fn().mockResolvedValue(undefined), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { flowModel: { findFirst: mockFlowFindFirst } }, + transaction: mockDbTransaction, + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + desc: (...args: unknown[]) => ({ desc: args }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + flowModel: { id: "flowModel.id" }, + flowVersionModel: { + id: "flowVersionModel.id", + flowId: "flowVersionModel.flowId", + isLatest: "flowVersionModel.isLatest", + }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: (_key: string, fn: () => Promise): Promise => + fn(), + invalidateCacheByTags: mockInvalidateCacheTags, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/errors", () => ({ + notFoundException: (message: string) => new Error(message), +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { flowVersionService } = await import("../src/flow-version/service") + +describe("flowVersionService.publish", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("resets other latest versions, syncs the draft, inserts the published version, repoints currentVersionId, invalidates cache, and audits", async () => { + mockFlowFindFirst.mockResolvedValue({ + id: "flow-1", + workspaceId: "ws-1", + flowVersions: [{ id: "draft-1", startNodeId: "node-1" }], + }) + mockCreateId.mockReturnValue("new-version-1") + mockDbTransaction.mockImplementation( + async ( + fn: (tx: { + insert: typeof mockTxInsert + update: typeof mockTxUpdate + }) => Promise, + ) => fn({ insert: mockTxInsert, update: mockTxUpdate }), + ) + + await flowVersionService.publish({ + workspaceId: "ws-1", + flowId: "flow-1", + nodes: [{ id: "node-1" }] as never, + edges: [] as never, + }) + + // 1) reset other latest versions + expect(mockTxUpdate).toHaveBeenNthCalledWith(1, { + id: "flowVersionModel.id", + flowId: "flowVersionModel.flowId", + isLatest: "flowVersionModel.isLatest", + }) + expect(mockTxSet).toHaveBeenNthCalledWith(1, { isLatest: false }) + + // 2) sync draft nodes/edges + expect(mockTxSet).toHaveBeenNthCalledWith(2, { + nodes: [{ id: "node-1" }], + edges: [], + }) + + // 3) insert new published version + expect(mockTxInsert).toHaveBeenCalledWith({ + id: "flowVersionModel.id", + flowId: "flowVersionModel.flowId", + isLatest: "flowVersionModel.isLatest", + }) + expect(mockTxInsertValues).toHaveBeenCalledWith({ + id: "new-version-1", + workspaceId: "ws-1", + flowId: "flow-1", + isDraft: false, + isLatest: true, + nodes: [{ id: "node-1" }], + edges: [], + startNodeId: "node-1", + }) + + // 4) repoint currentVersionId + expect(mockTxSet).toHaveBeenNthCalledWith(3, { + currentVersionId: "new-version-1", + }) + + expect(mockInvalidateCacheTags).toHaveBeenCalledWith([ + "flows:flow-1:versions", + ]) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "publish", + detail: "published a flow (#flow-1)", + }) + }) + + test("throws notFoundException when the flow does not exist", async () => { + mockFlowFindFirst.mockResolvedValue(undefined) + + await expect( + flowVersionService.publish({ + workspaceId: "ws-1", + flowId: "missing", + nodes: [] as never, + edges: [] as never, + }), + ).rejects.toThrow("Flow not found") + + expect(mockDbTransaction).not.toHaveBeenCalled() + }) + + test("throws notFoundException when the flow has no draft version", async () => { + mockFlowFindFirst.mockResolvedValue({ + id: "flow-1", + workspaceId: "ws-1", + flowVersions: [], + }) + + await expect( + flowVersionService.publish({ + workspaceId: "ws-1", + flowId: "flow-1", + nodes: [] as never, + edges: [] as never, + }), + ).rejects.toThrow("Flow not found") + + expect(mockDbTransaction).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/flow.service.test.ts b/packages/business/__tests__/flow.service.test.ts index 3d8a7f4ad8..1e24e16736 100644 --- a/packages/business/__tests__/flow.service.test.ts +++ b/packages/business/__tests__/flow.service.test.ts @@ -54,6 +54,13 @@ vi.mock("@chatbotx.io/database/client", () => ({ }, })) +// The repositories barrel transitively pulls in the contact-filter query +// builders, which read schema models this file does not mock. flowService only +// uses `listIdsByIds` (covered elsewhere), so a stub keeps that chain out. +vi.mock("@chatbotx.io/database/repositories", () => ({ + flowRepository: { listIdsByIds: vi.fn(async () => []) }, +})) + vi.mock("@chatbotx.io/database/partials", () => ({ rootFolderId: "0", })) diff --git a/packages/business/__tests__/sequence-service.test.ts b/packages/business/__tests__/sequence-service.test.ts new file mode 100644 index 0000000000..4e23de9b9f --- /dev/null +++ b/packages/business/__tests__/sequence-service.test.ts @@ -0,0 +1,241 @@ +// @vitest-environment node + +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateId, + mockInsert, + mockInsertValues, + mockFindOrFail, + mockIsDatabaseError, + mockDelete, + mockDispatchAuditRecord, + mockStepFindFirst, + mockStepUpdate, + mockStepInsert, + mockStepDelete, + sequenceModelStub, + sequenceStepModelStub, +} = vi.hoisted(() => { + const mockInsertValues = vi.fn().mockResolvedValue(undefined) + const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) + const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere }) + + const mockStepUpdateReturning = vi.fn() + const mockStepUpdateWhere = vi + .fn() + .mockReturnValue({ returning: mockStepUpdateReturning }) + const mockStepUpdateSet = vi + .fn() + .mockReturnValue({ where: mockStepUpdateWhere }) + const mockStepUpdate = vi.fn().mockReturnValue({ set: mockStepUpdateSet }) + + const mockStepInsertReturning = vi.fn() + const mockStepInsertValues = vi + .fn() + .mockReturnValue({ returning: mockStepInsertReturning }) + const mockStepInsert = vi + .fn() + .mockReturnValue({ values: mockStepInsertValues }) + + const mockStepDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockStepDelete = vi.fn().mockReturnValue({ where: mockStepDeleteWhere }) + + return { + mockCreateId: vi.fn(() => "generated-id"), + mockInsert, + mockInsertValues, + mockFindOrFail: vi.fn(), + mockIsDatabaseError: vi.fn(() => false), + mockDelete, + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + mockStepFindFirst: vi.fn(), + mockStepUpdate, + mockStepInsert, + mockStepDelete, + sequenceModelStub: { + id: "sequenceModel.id", + workspaceId: "sequenceModel.workspaceId", + }, + sequenceStepModelStub: { id: "sequenceStepModel.id" }, + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + insert: (model: unknown) => + model === sequenceStepModelStub ? mockStepInsert() : mockInsert(), + delete: (model: unknown) => + model === sequenceStepModelStub ? mockStepDelete() : mockDelete(), + update: () => mockStepUpdate(), + query: { + sequenceStepModel: { findFirst: mockStepFindFirst }, + }, + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + findOrFail: mockFindOrFail, + isDatabaseError: mockIsDatabaseError, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + sequenceModel: sequenceModelStub, + sequenceStepModel: sequenceStepModelStub, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { sequenceService } = await import("../src/sequence/service") + +const WS = "ws-1" + +describe("sequenceService.create", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("creates the sequence and audits", async () => { + mockInsert.mockReturnValue({ values: mockInsertValues }) + mockInsertValues.mockResolvedValue(undefined) + + const result = await sequenceService.create({ + workspaceId: WS, + name: "My Sequence", + }) + + expect(result).toEqual({ sequenceId: "generated-id" }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "create", + detail: "created a new sequence (#generated-id)", + }) + }) + + test("throws validationException on the name field for a 23505 unique violation", async () => { + const dbError = Object.assign(new Error("unique violation"), { + cause: { code: "23505" }, + }) + mockInsertValues.mockRejectedValueOnce(dbError) + mockIsDatabaseError.mockReturnValueOnce(true) + + await expect( + sequenceService.create({ workspaceId: WS, name: "Duplicate" }), + ).rejects.toMatchObject({ + code: "validation", + field: "name", + message: "Name is already taken.", + }) + }) + + test("rethrows non-23505 database errors", async () => { + const dbError = Object.assign(new Error("other db error"), { + cause: { code: "XXXXX" }, + }) + mockInsertValues.mockRejectedValueOnce(dbError) + mockIsDatabaseError.mockReturnValueOnce(true) + + await expect( + sequenceService.create({ workspaceId: WS, name: "Seq" }), + ).rejects.toThrow("other db error") + }) +}) + +describe("sequenceService.delete", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("verifies ownership, deletes, and audits with the sequence id", async () => { + mockFindOrFail.mockResolvedValue({ id: "seq-1" }) + + await sequenceService.delete({ workspaceId: WS, id: "seq-1" }) + + expect(mockDelete).toHaveBeenCalled() + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "delete", + detail: "deleted a sequence (#seq-1)", + }) + }) + + test("propagates the not-found error and never deletes", async () => { + mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) + + await expect( + sequenceService.delete({ workspaceId: WS, id: "missing" }), + ).rejects.toThrow("Sequence not found") + + expect(mockDelete).not.toHaveBeenCalled() + }) +}) + +describe("sequenceService.updateStep / deleteStep cross-workspace rejection", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("updateStep throws when the step does not exist", async () => { + mockStepFindFirst.mockResolvedValue(undefined) + + await expect( + sequenceService.updateStep({ + workspaceId: WS, + stepId: "step-1", + data: { order: 0 }, + }), + ).rejects.toThrow("Step not found") + }) + + test("updateStep throws when the step belongs to a different workspace", async () => { + mockStepFindFirst.mockResolvedValue({ + id: "step-1", + order: 1, + sequence: { workspaceId: "other-ws" }, + }) + + await expect( + sequenceService.updateStep({ + workspaceId: WS, + stepId: "step-1", + data: { order: 0 }, + }), + ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") + }) + + test("deleteStep throws when the step does not exist", async () => { + mockStepFindFirst.mockResolvedValue(undefined) + + await expect( + sequenceService.deleteStep({ workspaceId: WS, stepId: "step-1" }), + ).rejects.toThrow("Step not found") + }) + + test("deleteStep throws when the step belongs to a different workspace", async () => { + mockStepFindFirst.mockResolvedValue({ + id: "step-1", + sequence: { workspaceId: "other-ws" }, + }) + + await expect( + sequenceService.deleteStep({ workspaceId: WS, stepId: "step-1" }), + ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") + + expect(mockStepDelete).not.toHaveBeenCalled() + }) + + test("deleteStep deletes when the step belongs to the workspace", async () => { + mockStepFindFirst.mockResolvedValue({ + id: "step-1", + sequence: { workspaceId: WS }, + }) + + await sequenceService.deleteStep({ workspaceId: WS, stepId: "step-1" }) + + expect(mockStepDelete).toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/trigger-service-update-settings.test.ts b/packages/business/__tests__/trigger-service-update-settings.test.ts new file mode 100644 index 0000000000..d92780bdc2 --- /dev/null +++ b/packages/business/__tests__/trigger-service-update-settings.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment node + +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockTriggerFindFirst, + mockDbUpdate, + mockDbUpdateReturning, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockDbUpdateReturning = vi.fn().mockResolvedValue([{ id: "trigger-1" }]) + const mockDbUpdateWhere = vi + .fn() + .mockReturnValue({ returning: mockDbUpdateReturning }) + const mockDbUpdateSet = vi.fn().mockReturnValue({ where: mockDbUpdateWhere }) + const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbUpdateSet }) + + return { + mockTriggerFindFirst: vi.fn(), + mockDbUpdate, + mockDbUpdateReturning, + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { triggerModel: { findFirst: mockTriggerFindFirst } }, + update: mockDbUpdate, + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + inArray: (...args: unknown[]) => ({ inArray: args }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + conditionModel: {}, + triggerModel: { id: "triggerModel.id" }, +})) + +vi.mock("@chatbotx.io/events", () => ({ + removeTriggerCache: vi.fn(), + updateTriggerCache: vi.fn(), +})) + +vi.mock("../src/errors", () => ({ + notFoundException: (message: string) => new Error(message), +})) + +vi.mock("../src/folder/service", () => ({ + folderService: { ensureExists: vi.fn() }, +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { triggerService } = await import("../src/trigger/service") + +const WS = "ws-1" +const TRIGGER_ID = "trigger-1" + +describe("triggerService.updateSettings", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("throws notFoundException when the trigger does not exist", async () => { + mockTriggerFindFirst.mockResolvedValue(undefined) + + await expect( + triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + name: "New name", + }), + ).rejects.toThrow("Trigger not found") + + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + test("no-ops without writing or auditing when nothing changed", async () => { + mockTriggerFindFirst.mockResolvedValue({ + id: TRIGGER_ID, + name: "Same name", + active: true, + }) + + await triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + name: "Same name", + }) + + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("audits as 'enabled' when only active flips to true", async () => { + mockTriggerFindFirst.mockResolvedValue({ + id: TRIGGER_ID, + name: "Trigger", + active: false, + }) + + await triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + active: true, + }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `enabled a trigger (#${TRIGGER_ID})`, + }) + }) + + test("audits as 'disabled' when only active flips to false", async () => { + mockTriggerFindFirst.mockResolvedValue({ + id: TRIGGER_ID, + name: "Trigger", + active: true, + }) + + await triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + active: false, + }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `disabled a trigger (#${TRIGGER_ID})`, + }) + }) + + test("audits a generic 'updated' detail when a non-active field changes", async () => { + mockTriggerFindFirst.mockResolvedValue({ + id: TRIGGER_ID, + name: "Old name", + active: true, + }) + + await triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + name: "New name", + }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `updated a trigger (#${TRIGGER_ID})`, + }) + }) + + test("does not audit when the update affects zero rows", async () => { + mockTriggerFindFirst.mockResolvedValue({ + id: TRIGGER_ID, + name: "Old name", + active: true, + }) + mockDbUpdateReturning.mockResolvedValueOnce([]) + + await triggerService.updateSettings({ + workspaceId: WS, + id: TRIGGER_ID, + name: "New name", + }) + + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/__tests__/trigger-service-update-with-conditions.test.ts b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts new file mode 100644 index 0000000000..810f084ba8 --- /dev/null +++ b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateId, + mockDbTransaction, + mockTriggerFindFirst, + mockConditionFindMany, + mockTxUpdate, + mockTxUpdateSet, + mockTxUpdateReturning, + mockTxDelete, + mockTxDeleteWhere, + mockTxInsert, + mockTxInsertValues, + mockUpdateTriggerCache, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockTxUpdateReturning = vi.fn().mockResolvedValue([]) + const mockTxUpdateWhere = vi + .fn() + .mockReturnValue({ returning: mockTxUpdateReturning }) + const mockTxUpdateSet = vi.fn().mockReturnValue({ where: mockTxUpdateWhere }) + const mockTxUpdate = vi.fn().mockReturnValue({ set: mockTxUpdateSet }) + const mockTxDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockTxDelete = vi.fn().mockReturnValue({ where: mockTxDeleteWhere }) + const mockTxInsertValues = vi.fn().mockResolvedValue(undefined) + const mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }) + + return { + mockCreateId: vi.fn(() => "new-condition-id"), + mockDbTransaction: vi.fn(), + mockTriggerFindFirst: vi.fn(), + mockConditionFindMany: vi.fn(), + mockTxUpdate, + mockTxUpdateSet, + mockTxUpdateReturning, + mockTxDelete, + mockTxDeleteWhere, + mockTxInsert, + mockTxInsertValues, + mockUpdateTriggerCache: vi.fn().mockResolvedValue(undefined), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +const tx = { + query: { + triggerModel: { findFirst: mockTriggerFindFirst }, + conditionModel: { findMany: mockConditionFindMany }, + }, + update: mockTxUpdate, + delete: mockTxDelete, + insert: mockTxInsert, +} + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: mockDbTransaction }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + inArray: (...args: unknown[]) => ({ inArray: args }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + conditionModel: { id: "conditionModel.id" }, + triggerModel: { + id: "triggerModel.id", + workspaceId: "triggerModel.workspaceId", + }, +})) + +vi.mock("@chatbotx.io/events", () => ({ + removeTriggerCache: vi.fn(), + updateTriggerCache: mockUpdateTriggerCache, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/folder/service", () => ({ + folderService: { ensureExists: vi.fn() }, +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { triggerService } = await import("../src/trigger/service") + +const WS = "ws-1" +const TRIGGER_ID = "trigger-1" + +describe("triggerService.updateWithConditions", () => { + beforeEach(() => { + mockDbTransaction.mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(tx), + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test("returns undefined and makes no writes when the trigger does not exist", async () => { + mockTriggerFindFirst.mockResolvedValue(undefined) + mockConditionFindMany.mockResolvedValue([]) + + const result = await triggerService.updateWithConditions({ + workspaceId: WS, + id: TRIGGER_ID, + actions: [], + conditions: [], + }) + + expect(result).toBeUndefined() + expect(mockUpdateTriggerCache).not.toHaveBeenCalled() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("does not audit or refresh cache when nothing actually changed (hasRealChange gate)", async () => { + mockTriggerFindFirst + .mockResolvedValueOnce({ + id: TRIGGER_ID, + actions: [{ type: "sendMessage" }], + }) + .mockResolvedValueOnce({ id: TRIGGER_ID }) + mockConditionFindMany.mockResolvedValue([]) + + const result = await triggerService.updateWithConditions({ + workspaceId: WS, + id: TRIGGER_ID, + actions: [{ type: "sendMessage" }], + conditions: [], + }) + + expect(result).toEqual({ id: TRIGGER_ID }) + // Cache updates when the trigger exists, regardless of hasRealChange. + expect(mockUpdateTriggerCache).toHaveBeenCalledWith(WS) + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("audits when actions changed", async () => { + mockTriggerFindFirst + .mockResolvedValueOnce({ + id: TRIGGER_ID, + actions: [{ type: "sendMessage" }], + }) + .mockResolvedValueOnce({ id: TRIGGER_ID }) + mockConditionFindMany.mockResolvedValue([]) + mockTxUpdateReturning.mockResolvedValue([{ id: TRIGGER_ID }]) + + await triggerService.updateWithConditions({ + workspaceId: WS, + id: TRIGGER_ID, + actions: [{ type: "sendMessageV2" }], + conditions: [], + }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `updated a trigger (#${TRIGGER_ID})`, + }) + }) + + test("partitions conditions into delete/update/create and applies each", async () => { + mockTriggerFindFirst + .mockResolvedValueOnce({ id: TRIGGER_ID, actions: [] }) + .mockResolvedValueOnce({ id: TRIGGER_ID }) + mockConditionFindMany.mockResolvedValue([ + { + id: "cond-keep-changed", + type: "tagApplied", + sourceId: "tag-old", + operator: null, + value: null, + }, + { + id: "cond-delete", + type: "tagApplied", + sourceId: "tag-2", + operator: null, + value: null, + }, + ]) + + await triggerService.updateWithConditions({ + workspaceId: WS, + id: TRIGGER_ID, + actions: [], + conditions: [ + { + id: "cond-keep-changed", + type: "tagApplied", + sourceId: "tag-new", + }, + { type: "newContact" }, + ], + }) + + // deletes the condition not resubmitted + expect(mockTxDelete).toHaveBeenCalledWith({ id: "conditionModel.id" }) + expect(mockTxDeleteWhere).toHaveBeenCalledWith({ + inArray: ["conditionModel.id", ["cond-delete"]], + }) + + // updates the changed condition + expect(mockTxUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ sourceId: "tag-new" }), + ) + + // creates the new condition + expect(mockTxInsertValues).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "new-condition-id", + triggerId: TRIGGER_ID, + type: "newContact", + }), + ]) + }) +}) diff --git a/packages/business/__tests__/webhook-service-builder-methods.test.ts b/packages/business/__tests__/webhook-service-builder-methods.test.ts new file mode 100644 index 0000000000..3cbd6acc8f --- /dev/null +++ b/packages/business/__tests__/webhook-service-builder-methods.test.ts @@ -0,0 +1,214 @@ +// @vitest-environment node + +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateId, + mockInsert, + mockWebhookFindMany, + mockDelete, + mockUpdateWebhookCache, + mockRemoveWebhookCache, + mockEnsureExists, + mockWebhookFindFirst, + mockUpdateReturning, + mockDbUpdate, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockInsertReturning = vi.fn() + const mockInsertValues = vi + .fn() + .mockReturnValue({ returning: mockInsertReturning }) + const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) + const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere }) + const mockUpdateReturning = vi.fn() + const mockUpdateWhere = vi + .fn() + .mockReturnValue({ returning: mockUpdateReturning }) + const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + const mockDbUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) + + return { + mockCreateId: vi.fn(() => "generated-id"), + mockInsert, + mockWebhookFindMany: vi.fn(), + mockDelete, + mockUpdateWebhookCache: vi.fn().mockResolvedValue(undefined), + mockRemoveWebhookCache: vi.fn().mockResolvedValue(undefined), + mockEnsureExists: vi.fn().mockResolvedValue(undefined), + mockWebhookFindFirst: vi.fn(), + mockUpdateReturning, + mockDbUpdate, + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + insert: mockInsert, + delete: mockDelete, + update: mockDbUpdate, + $count: vi.fn(), + query: { + webhookModel: { + findMany: mockWebhookFindMany, + findFirst: mockWebhookFindFirst, + }, + }, + }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + inArray: (...args: unknown[]) => ({ inArray: args }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + conditionModel: {}, + webhookModel: { + id: "webhookModel.id", + workspaceId: "webhookModel.workspaceId", + }, +})) + +vi.mock("@chatbotx.io/events", () => ({ + updateWebhookCache: mockUpdateWebhookCache, + removeWebhookCache: mockRemoveWebhookCache, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + distributedLock: { runExclusive: vi.fn() }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/net/ssrf-guard", () => ({ + assertPublicUrl: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../src/folder/service", () => ({ + folderService: { ensureExists: mockEnsureExists }, +})) + +vi.mock("../src/trigger/condition-columns", () => ({ + toConditionColumnsShared: (condition: { + type: string + sourceId?: string | null + operator?: string | null + value?: unknown + }) => ({ + type: condition.type, + sourceId: condition.sourceId ?? null, + operator: condition.operator ?? null, + value: condition.value ?? null, + }), +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { webhookService } = await import("../src/webhook/service") + +const WS = "ws-1" + +describe("webhookService.deleteMany", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("pluralizes the audit detail for multiple ids", async () => { + mockWebhookFindMany.mockResolvedValue([ + { id: "webhook-1" }, + { id: "webhook-2" }, + ]) + + await webhookService.deleteMany({ + workspaceId: WS, + ids: ["webhook-1", "webhook-2"], + }) + + expect(mockDelete).toHaveBeenCalled() + expect(mockRemoveWebhookCache).toHaveBeenCalledWith(WS) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "delete", + detail: "deleted webhooks (#webhook-1, #webhook-2)", + }) + }) + + test("does not pluralize for a single id", async () => { + mockWebhookFindMany.mockResolvedValue([{ id: "webhook-1" }]) + + await webhookService.deleteMany({ workspaceId: WS, ids: ["webhook-1"] }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "delete", + detail: "deleted webhook (#webhook-1)", + }) + }) + + test("does not audit when no rows matched", async () => { + mockWebhookFindMany.mockResolvedValue([]) + + await webhookService.deleteMany({ workspaceId: WS, ids: ["missing"] }) + + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) +}) + +describe("webhookService.updateSettings", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("throws notFoundException when the webhook does not exist", async () => { + mockWebhookFindFirst.mockResolvedValue(undefined) + + await expect( + webhookService.updateSettings({ + workspaceId: WS, + id: "webhook-1", + name: "New", + }), + ).rejects.toThrow("Webhook not found") + }) + + test("early-returns without writing when nothing changed", async () => { + mockWebhookFindFirst.mockResolvedValue({ + id: "webhook-1", + name: "Same", + active: true, + }) + + await webhookService.updateSettings({ + workspaceId: WS, + id: "webhook-1", + name: "Same", + }) + + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("updates, refreshes cache, and audits when something changed", async () => { + mockWebhookFindFirst.mockResolvedValue({ + id: "webhook-1", + name: "Old", + active: true, + }) + mockUpdateReturning.mockResolvedValue([{ id: "webhook-1" }]) + + await webhookService.updateSettings({ + workspaceId: WS, + id: "webhook-1", + name: "New", + }) + + expect(mockUpdateWebhookCache).toHaveBeenCalledWith(WS) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: "updated a webhook (#webhook-1)", + }) + }) +}) diff --git a/packages/business/__tests__/webhook.service.test.ts b/packages/business/__tests__/webhook.service.test.ts index 2ed6b6f499..c3a8bbacb7 100644 --- a/packages/business/__tests__/webhook.service.test.ts +++ b/packages/business/__tests__/webhook.service.test.ts @@ -37,9 +37,11 @@ vi.mock("@chatbotx.io/database/client", () => ({ transaction: mocks.transaction, delete: mocks.deleteFn, insert: vi.fn(() => mocks.insertBuilder), + query: { webhookModel: { findMany: vi.fn(async () => []) } }, }, eq: vi.fn(() => "eq"), and: vi.fn(() => "and"), + inArray: vi.fn(() => "inArray"), })) vi.mock("@chatbotx.io/database/schema", () => ({ @@ -75,6 +77,20 @@ vi.mock("../src/folder/service", () => ({ const dispatchAuditRecord = vi.fn(async () => undefined) vi.mock("../src/audit/dispatcher", () => ({ dispatchAuditRecord })) +vi.mock("../src/trigger/condition-columns", () => ({ + toConditionColumnsShared: (condition: { + type: string + sourceId?: string | null + operator?: string | null + value?: unknown + }) => ({ + type: condition.type, + sourceId: condition.sourceId ?? null, + operator: condition.operator ?? null, + value: condition.value ?? null, + }), +})) + const { updateWebhookCache, removeWebhookCache } = await import( "@chatbotx.io/events" ) diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index aad3a7db64..9021af8b36 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -5,6 +5,7 @@ import { db, desc, eq, + findOrFail, gt, inArray, isNotNull, @@ -49,9 +50,10 @@ import type { } from "@chatbotx.io/database/types" import { chunkById, likeContains } from "@chatbotx.io/database/utils" import type { WaTemplateParams } from "@chatbotx.io/flow-config" +import { createId } from "@chatbotx.io/utils" import { startOfMinute } from "date-fns" import { BaseService } from "../base.service" -import { ChatbotXException } from "../errors" +import { ChatbotXException, validationException } from "../errors" import { inboxService } from "../inbox/service" import type { BroadcastAudienceInput, @@ -1071,6 +1073,182 @@ class BroadcastService extends BaseService { { chunkSize, callback: onChunk }, ) } + + /** + * Validates channel/subaction/flow-or-template rules, verifies any + * integration ids and the flow/template actually belong to the workspace, + * resolves the broadcast's stored `name`, inserts it, and audits — the + * full `createBroadcastAction` body. Every validation failure throws + * `validationException(field, message)` so the action can re-map it to a + * `returnValidationErrors` payload on the same field. + */ + async create( + input: UpdateDraftBroadcastData & { + workspaceId: string + canViewEmailAndPhone: boolean + }, + ): Promise { + const { workspaceId, canViewEmailAndPhone, buttons, saveAsDraft, ...rest } = + input + + const capability = findBroadcastChannelCapability(rest.channel) + if (!capability) { + throw validationException("channel", "Unsupported broadcast channel") + } + if (!capability.subactions.includes(rest.subaction)) { + throw validationException("subaction", "Unsupported broadcast subaction") + } + if (!(rest.flowId || rest.templateId)) { + throw validationException( + "flowId", + "Either flow or template must be selected", + ) + } + if (rest.templateId && !capability.supportsTemplateBroadcast) { + throw validationException( + "templateId", + "Template broadcasts are not supported for this channel", + ) + } + + // Never trust integration ids from the client: they scope the audience, + // so a foreign id would let a broadcast target another workspace's pages. + // Checked independently so the validation error lands on the field that + // actually failed (the original action validated each id on its own). + if (rest.integrationMessengerId) { + await this.assertBroadcastIntegrationsOwned({ + workspaceId, + integrationMessengerId: rest.integrationMessengerId, + }).catch(() => { + throw validationException( + "integrationMessengerId", + "Integration not found", + ) + }) + } + if (rest.integrationWhatsappId) { + await this.assertBroadcastIntegrationsOwned({ + workspaceId, + integrationWhatsappId: rest.integrationWhatsappId, + }).catch(() => { + throw validationException( + "integrationWhatsappId", + "Integration not found", + ) + }) + } + + let broadcastName = DEFAULT_BROADCAST_NAME + if (rest.flowId) { + broadcastName = await this.requireFlowName( + workspaceId, + rest.flowId, + ).catch(() => { + throw validationException("flowId", "Flow not found") + }) + } + + if (rest.templateId) { + const templateBroadcastName = await this.resolveTemplateBroadcastName({ + workspaceId, + channel: rest.channel, + templateId: rest.templateId, + integrationMessengerId: rest.integrationMessengerId, + integrationWhatsappId: rest.integrationWhatsappId, + }) + + if (!templateBroadcastName) { + throw validationException("templateId", "Template not found") + } + + broadcastName = templateBroadcastName + } + + const contactFilter = pruneEmailPhoneFilterConditions( + rest.contactFilter, + canViewEmailAndPhone, + ) + + const [broadcast] = await db + .insert(broadcastModel) + .values({ + ...rest, + contactFilter, + name: broadcastName, + workspaceId, + status: saveAsDraft ? "draft" : "scheduled", + schedulesAt: startOfMinute(new Date(rest.schedulesAt ?? new Date())), + templateData: rest.templateData + ? { + ...(rest.templateData as Record), + buttons: buttons ?? [], + } + : null, + }) + .returning() + + await this.audit("create", `created a new broadcast (#${broadcast.id})`) + + // A draft is never launched — it only leaves `draft` through + // `scheduleBroadcastAction`, which records its own `launch` entry. + if (rest.schedulesType === "now" && !saveAsDraft) { + await this.audit("launch", `launched a broadcast (#${broadcast.id})`) + } + + return broadcast + } + + /** + * Clones a `sent`/`failed` broadcast as a new immediately-scheduled one. + * The transaction wraps a single insert — kept verbatim rather than + * simplified, to avoid any semantic argument about what belongs inside it. + */ + async resend(input: { + workspaceId: string + id: string + contactFilter?: ContactFilterCriteriaInput | null + }): Promise { + const broadcast = await findOrFail({ + table: broadcastModel, + where: { + id: input.id, + workspaceId: input.workspaceId, + deletedAt: { isNull: true }, + }, + }) + if (broadcast.status !== "sent" && broadcast.status !== "failed") { + throw new ChatbotXException("Broadcast is not sent") + } + + const newBroadcast = await db.transaction(async (tx) => { + const inserted = await tx + .insert(broadcastModel) + .values({ + workspaceId: input.workspaceId, + flowId: broadcast.flowId, + integrationWhatsappId: broadcast.integrationWhatsappId, + integrationMessengerId: broadcast.integrationMessengerId, + channel: broadcast.channel, + subaction: broadcast.subaction, + templateId: broadcast.templateId, + templateData: broadcast.templateData, + status: "scheduled", + schedulesType: "now", + schedulesAt: new Date(), + contactFilter: input.contactFilter, + name: `${broadcast.name} (Resend)`, + id: createId(), + }) + .returning() + .then((result) => result[0]) + + return inserted + }) + + await this.audit("launch", `launched a broadcast (#${newBroadcast.id})`) + + return newBroadcast + } } export const broadcastService = new BroadcastService() diff --git a/packages/business/src/errors.ts b/packages/business/src/errors.ts index 418deb5ff4..85be7c1a04 100644 --- a/packages/business/src/errors.ts +++ b/packages/business/src/errors.ts @@ -177,6 +177,13 @@ export class ChatbotXException extends Error { export const notFoundException = (message: string) => new ChatbotXException(message, "notFound", 404) +/** + * A field-scoped validation failure raised from inside a service. The + * caller-facing action maps `error.field` back to a + * `returnValidationErrors(schema, { [field]: { _errors: [message] } })` + * payload, so the exact `field` name must match the form field it should + * attach to. + */ export const validationException = ( field: string, message: string, diff --git a/packages/business/src/flow-version/service.ts b/packages/business/src/flow-version/service.ts index 8ed372f94a..b4b96b2e82 100644 --- a/packages/business/src/flow-version/service.ts +++ b/packages/business/src/flow-version/service.ts @@ -8,6 +8,7 @@ import { import { flowModel, flowVersionModel } from "@chatbotx.io/database/schema" import type { FlowVersionModel } from "@chatbotx.io/database/types" import { withCache } from "@chatbotx.io/redis" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { notFoundException } from "../errors" @@ -302,6 +303,86 @@ class FlowVersionService extends BaseService { where: { id: versionId, workspaceId }, }) } + + /** + * Publishes the flow's draft version as a new immutable version: resets + * every other `isLatest` version for the flow, syncs the draft's + * nodes/edges to what was just published, inserts the new published + * version, and repoints `Flow.currentVersionId` at it — all in one + * transaction, mirroring `publish-flow-action.ts` verbatim. + */ + async publish(input: { + workspaceId: string + flowId: string + nodes: FlowVersionModel["nodes"] + edges: FlowVersionModel["edges"] + }): Promise { + const flow = await db.query.flowModel.findFirst({ + where: { + id: input.flowId, + workspaceId: input.workspaceId, + }, + with: { + flowVersions: { + where: { + isDraft: true, + }, + }, + }, + }) + + if (!flow || flow.flowVersions.length === 0) { + throw notFoundException("Flow not found") + } + + const draftVersion = flow.flowVersions[0] + + await db.transaction(async (tx) => { + // Remove all other latest versions + await tx + .update(flowVersionModel) + .set({ + isLatest: false, + }) + .where( + and( + eq(flowVersionModel.flowId, flow.id), + eq(flowVersionModel.isLatest, true), + ), + ) + + await tx + .update(flowVersionModel) + .set({ + nodes: input.nodes, + edges: input.edges, + }) + .where(eq(flowVersionModel.id, draftVersion.id)) + + const newVersionId = createId() + await tx.insert(flowVersionModel).values({ + id: newVersionId, + workspaceId: flow.workspaceId, + flowId: flow.id, + isDraft: false, + isLatest: true, + nodes: input.nodes, + edges: input.edges, + startNodeId: draftVersion.startNodeId, + }) + + await tx + .update(flowModel) + .set({ + currentVersionId: newVersionId, + }) + .where(eq(flowModel.id, flow.id)) + }) + + await this.invalidateCacheTags(`flows:${flow.id}:versions`) + + await this.audit("publish", `published a flow (#${flow.id})`) + } } export const flowVersionService = new FlowVersionService() diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index c10ef08dc2..eefc77c9cf 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -3,6 +3,7 @@ import { type CustomFieldType, rootFolderId, } from "@chatbotx.io/database/partials" +import { flowRepository } from "@chatbotx.io/database/repositories" import { flowAnalyticsSessionModel, flowModel, @@ -488,6 +489,21 @@ class FlowService extends BaseService { where: { workspaceId, active: true }, }) } + + /** Existence check for a set of flow ids, scoped to the workspace. */ + async assertAllExist(input: { + workspaceId: string + flowIds: string[] + }): Promise { + const ids = await flowRepository.listIdsByIds({ + workspaceId: input.workspaceId, + ids: input.flowIds, + }) + + if (ids.length !== input.flowIds.length) { + throw notFoundException("Flow does not exists.") + } + } } export const flowService = new FlowService() diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index 6f0bd1dfed..8365f6c623 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -86,6 +86,7 @@ export { parseLiveCount } from "./quota-shared/live-counter-store" export * from "./referral" export * from "./reflink" export * from "./saved-reply" +export * from "./sequence" export * from "./smart-delay" export * from "./spreadsheet" export * from "./tag" diff --git a/packages/business/src/saved-reply/service.ts b/packages/business/src/saved-reply/service.ts index 02a7ce3f55..b55a2282a0 100644 --- a/packages/business/src/saved-reply/service.ts +++ b/packages/business/src/saved-reply/service.ts @@ -2,6 +2,8 @@ import { db, eq, findOrFail } from "@chatbotx.io/database/client" import { savedReplyModel } from "@chatbotx.io/database/schema" import { assertDeletable } from "../template/installed-resource.service" +type SavedReplyModel = typeof savedReplyModel.$inferSelect + class SavedReplyService { async delete(input: { workspaceId: string; id: string }): Promise { const savedReply = await findOrFail({ @@ -20,6 +22,13 @@ class SavedReplyService { .delete(savedReplyModel) .where(eq(savedReplyModel.id, savedReply.id)) } + + async listByWorkspaceId(workspaceId: string): Promise { + return await db.query.savedReplyModel.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "asc" }, + }) + } } export const savedReplyService = new SavedReplyService() diff --git a/packages/business/src/sequence/index.ts b/packages/business/src/sequence/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/sequence/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/sequence/service.ts b/packages/business/src/sequence/service.ts new file mode 100644 index 0000000000..7c63a935d6 --- /dev/null +++ b/packages/business/src/sequence/service.ts @@ -0,0 +1,164 @@ +import { + and, + db, + eq, + findOrFail, + isDatabaseError, +} from "@chatbotx.io/database/client" +import { sequenceModel, sequenceStepModel } from "@chatbotx.io/database/schema" +import type { + SequenceModel, + SequenceStepModel, +} from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" +import { validationException } from "../errors" +import { + buildCreateData, + buildUpdateData, + type SequenceStepPayloadInput, +} from "./step-payload" + +const UNIQUE_VIOLATION_CODE = "23505" + +class SequenceService extends BaseService { + async create(input: { + workspaceId: string + name: string + folderId?: string | null + }): Promise<{ sequenceId: string }> { + const sequenceId = createId() + + try { + await db.insert(sequenceModel).values({ + id: sequenceId, + workspaceId: input.workspaceId, + name: input.name, + folderId: input.folderId || null, + }) + } catch (error) { + if ( + isDatabaseError(error) && + error.cause.code === UNIQUE_VIOLATION_CODE + ) { + throw validationException("name", "Name is already taken.") + } + throw error + } + + await this.audit("create", `created a new sequence (#${sequenceId})`) + + return { sequenceId } + } + + async delete(input: { workspaceId: string; id: string }): Promise { + const sequence = await findOrFail({ + table: sequenceModel, + where: { + id: input.id, + workspaceId: input.workspaceId, + }, + message: "Sequence not found", + }) + + await db + .delete(sequenceModel) + .where( + and( + eq(sequenceModel.id, input.id), + eq(sequenceModel.workspaceId, input.workspaceId), + ), + ) + + await this.audit("delete", `deleted a sequence (#${sequence.id})`) + } + + async assertOwned(input: { + workspaceId: string + sequenceId: string + }): Promise { + return await findOrFail({ + table: sequenceModel, + where: { + id: input.sequenceId, + workspaceId: input.workspaceId, + }, + message: "Sequence not found", + }) + } + + async createStep(input: { + workspaceId: string + sequenceId: string + data: SequenceStepPayloadInput + }): Promise { + const createData = buildCreateData(input.data, input.sequenceId, createId()) + const [created] = await db + .insert(sequenceStepModel) + .values(createData) + .returning() + + return created + } + + async updateStep(input: { + workspaceId: string + stepId: string + data: SequenceStepPayloadInput + }): Promise<{ previousOrder: number; step: SequenceStepModel }> { + const step = await db.query.sequenceStepModel.findFirst({ + where: { + id: input.stepId, + }, + with: { + sequence: true, + }, + }) + + if (!step) { + throw new Error("Step not found") + } + + if (step.sequence.workspaceId !== input.workspaceId) { + throw new Error("Unauthorized: Step does not belong to this workspace") + } + + const updateData = buildUpdateData(input.data) + + const [updated] = await db + .update(sequenceStepModel) + .set(updateData) + .where(eq(sequenceStepModel.id, input.stepId)) + .returning() + + return { previousOrder: step.order, step: updated } + } + + async deleteStep(input: { + workspaceId: string + stepId: string + }): Promise { + const step = await db.query.sequenceStepModel.findFirst({ + where: { + id: input.stepId, + }, + with: { + sequence: true, + }, + }) + + if (!step) { + throw new Error("Step not found") + } + + if (step.sequence.workspaceId !== input.workspaceId) { + throw new Error("Unauthorized: Step does not belong to this workspace") + } + + await db + .delete(sequenceStepModel) + .where(eq(sequenceStepModel.id, input.stepId)) + } +} + +export const sequenceService = new SequenceService() diff --git a/packages/business/src/sequence/step-payload.ts b/packages/business/src/sequence/step-payload.ts new file mode 100644 index 0000000000..5d27971be2 --- /dev/null +++ b/packages/business/src/sequence/step-payload.ts @@ -0,0 +1,95 @@ +import type { sequenceStepModel } from "@chatbotx.io/database/schema" + +/** + * The subset of `upsertSequenceStepRequest` fields relevant to a step's + * create/update payload — kept loose (`Partial`-friendly, all optional + * except `order`) so both `buildCreateData` and `buildUpdateData` accept the + * same shape the builder action already validates. + */ +export type SequenceStepPayloadInput = { + order: number + delayDays?: number + delayMinutes?: number + delayUnit?: string + flowId?: string | null + specificDateTime?: string | null + isActive?: boolean + anytime?: boolean + sendTimeStart?: string | null + sendTimeEnd?: string | null + sendDays?: string[] +} + +export function buildUpdateData( + parsedInput: SequenceStepPayloadInput, +): Partial { + const { + order, + delayDays, + delayMinutes, + delayUnit, + flowId, + specificDateTime, + isActive, + anytime, + sendTimeStart, + sendTimeEnd, + sendDays, + } = parsedInput + + return { + order, + ...(delayDays !== undefined && { delayDays }), + ...(delayMinutes !== undefined && { delayMinutes }), + ...(delayUnit !== undefined && { delayUnit }), + ...(flowId !== undefined && { flowId }), + ...(specificDateTime !== undefined && { + specificDateTime: specificDateTime ? new Date(specificDateTime) : null, + }), + ...(isActive !== undefined && { isActive }), + ...(anytime !== undefined && { anytime }), + ...(sendTimeStart !== undefined && { + sendTimeStart: sendTimeStart || null, + }), + ...(sendTimeEnd !== undefined && { sendTimeEnd: sendTimeEnd || null }), + ...(sendDays !== undefined && { + sendDays: sendDays ? JSON.stringify(sendDays) : null, + }), + } +} + +export function buildCreateData( + parsedInput: SequenceStepPayloadInput, + sequenceId: string, + id: string, +): typeof sequenceStepModel.$inferInsert { + const { + order, + delayDays, + delayMinutes, + delayUnit, + flowId, + specificDateTime, + isActive, + anytime, + sendTimeStart, + sendTimeEnd, + sendDays, + } = parsedInput + + return { + id, + sequenceId, + order, + delayDays: delayDays ?? 1, + delayMinutes: delayMinutes ?? 0, + delayUnit: delayUnit ?? "days", + flowId: flowId ?? null, + specificDateTime: specificDateTime ? new Date(specificDateTime) : null, + isActive: isActive ?? true, + anytime: anytime ?? true, + sendTimeStart: sendTimeStart || null, + sendTimeEnd: sendTimeEnd || null, + sendDays: sendDays ? JSON.stringify(sendDays) : null, + } +} diff --git a/packages/business/src/trigger/condition-columns.ts b/packages/business/src/trigger/condition-columns.ts new file mode 100644 index 0000000000..162a948414 --- /dev/null +++ b/packages/business/src/trigger/condition-columns.ts @@ -0,0 +1,20 @@ +/** + * The 4 columns a condition row carries — shared shape for trigger/webhook + * condition upserts. Both `trigger/service.ts` and `webhook/service.ts` use + * the identical mapping, so it lives in its own file rather than one domain + * importing the other's service module. + */ +export type ConditionInput = { + id?: string + type: string + sourceId?: string | null + operator?: string | null + value?: unknown +} + +export const toConditionColumnsShared = (condition: ConditionInput) => ({ + type: condition.type, + sourceId: condition.sourceId ?? null, + operator: condition.operator ?? null, + value: condition.value ?? null, +}) diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index 6fd91cd5fb..78f313128c 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -1,15 +1,22 @@ import { and, db, eq, inArray } from "@chatbotx.io/database/client" import type { FolderType } from "@chatbotx.io/database/partials" -import { triggerModel } from "@chatbotx.io/database/schema" +import { conditionModel, triggerModel } from "@chatbotx.io/database/schema" import type { TriggerModel } from "@chatbotx.io/database/types" import { removeTriggerCache, updateTriggerCache } from "@chatbotx.io/events" import { createId } from "@chatbotx.io/utils" +import { isSameJsonValue } from "../audit/diff" import { BaseService } from "../base.service" -import { validationException } from "../errors" +import { notFoundException, validationException } from "../errors" import { folderService } from "../folder/service" import { assertDeletable } from "../template/installed-resource.service" +import { + type ConditionInput, + toConditionColumnsShared as toConditionColumns, +} from "./condition-columns" import { MAX_TRIGGERS_PER_WORKSPACE } from "./constants" +export type { ConditionInput } from "./condition-columns" + class TriggerService extends BaseService { async create(input: { workspaceId: string @@ -97,6 +104,196 @@ class TriggerService extends BaseService { ) } } + + /** + * Replaces a trigger's `actions` and diffs its `conditions` (delete/update/ + * create) in one transaction. Audit and cache-invalidation only fire when + * something actually changed (`hasRealChange`) — a submit with no real + * delta is a silent no-op, matching the original action verbatim. + */ + async updateWithConditions(input: { + workspaceId: string + id: string + actions: TriggerModel["actions"] + conditions: ConditionInput[] + }): Promise { + const { workspaceId, id, actions, conditions } = input + + const result = await db.transaction(async (tx) => { + const [existingTrigger, existingConditions] = await Promise.all([ + tx.query.triggerModel.findFirst({ + where: { + id, + workspaceId, + }, + }), + tx.query.conditionModel.findMany({ + where: { + triggerId: id, + }, + }), + ]) + + if (!existingTrigger) { + return { trigger: undefined, hasRealChange: false } + } + + const existingIds = new Set(existingConditions.map((c) => c.id)) + const existingById = new Map(existingConditions.map((c) => [c.id, c])) + const submittedIds = new Set( + conditions.filter((c) => c.id).map((c) => c.id), + ) + + const conditionsToDelete = existingConditions.filter( + (existing) => !submittedIds.has(existing.id.toString()), + ) + + const conditionsToUpdate = conditions.filter( + (c) => c.id && existingIds.has(c.id), + ) + + const changedConditionsToUpdate = conditionsToUpdate.filter( + (condition) => { + const existing = condition.id + ? existingById.get(condition.id) + : undefined + if (!existing) { + return false + } + const next = toConditionColumns(condition) + return !isSameJsonValue(next, { + type: existing.type, + sourceId: existing.sourceId, + operator: existing.operator, + value: existing.value, + }) + }, + ) + + const conditionsToCreate = conditions.filter((c) => !c.id) + + let actionsChanged = false + if (!isSameJsonValue(actions, existingTrigger.actions)) { + const updated = await tx + .update(triggerModel) + .set({ actions }) + .where( + and( + eq(triggerModel.workspaceId, workspaceId), + eq(triggerModel.id, id), + ), + ) + .returning({ id: triggerModel.id }) + + actionsChanged = updated.length > 0 + } + + if (conditionsToDelete.length > 0) { + await tx.delete(conditionModel).where( + inArray( + conditionModel.id, + conditionsToDelete.map((c) => c.id), + ), + ) + } + + for (const condition of changedConditionsToUpdate) { + await tx + .update(conditionModel) + .set(toConditionColumns(condition)) + .where(eq(conditionModel.id, condition.id ?? "")) + } + + if (conditionsToCreate.length > 0) { + await tx.insert(conditionModel).values( + conditionsToCreate.map((c) => ({ + id: createId(), + triggerId: id, + ...toConditionColumns(c), + })), + ) + } + + const trigger = await tx.query.triggerModel.findFirst({ + where: { + id, + }, + }) + + return { + trigger, + hasRealChange: + actionsChanged || + conditionsToDelete.length > 0 || + changedConditionsToUpdate.length > 0 || + conditionsToCreate.length > 0, + } + }) + + if (result.trigger) { + await updateTriggerCache(workspaceId) + } + + if (result.hasRealChange) { + await this.audit("update", `updated a trigger (#${id})`) + } + + return result.trigger + } + + /** + * Applies a settings patch (`name`/`active`) after diffing against the + * current row — a no-op submit returns early without writing or auditing. + * The audit detail branches to `enabled`/`disabled` when the only change + * is `active` flipping, otherwise a generic `updated` detail. + */ + async updateSettings(input: { + workspaceId: string + id: string + name?: string + active?: boolean + }): Promise { + const { workspaceId, id, ...patch } = input + + const trigger = await db.query.triggerModel.findFirst({ + where: { + id, + workspaceId, + }, + }) + + if (!trigger) { + throw notFoundException("Trigger not found") + } + + const changedEntries = Object.entries(patch).filter( + ([key, value]) => trigger[key as keyof typeof patch] !== value, + ) + + if (changedEntries.length === 0) { + return + } + + const updated = await db + .update(triggerModel) + .set(patch) + .where(eq(triggerModel.id, trigger.id)) + .returning({ id: triggerModel.id }) + + if (updated.length === 0) { + return + } + + const changedKeys = changedEntries.map(([key]) => key) + let detail = `updated a trigger (#${trigger.id})` + if (changedKeys.length === 1 && changedKeys[0] === "active") { + detail = patch.active + ? `enabled a trigger (#${trigger.id})` + : `disabled a trigger (#${trigger.id})` + } + + await this.audit("update", detail) + } } export const triggerService = new TriggerService() diff --git a/packages/business/src/webhook/service.ts b/packages/business/src/webhook/service.ts index 73b7719054..b708c5bed6 100644 --- a/packages/business/src/webhook/service.ts +++ b/packages/business/src/webhook/service.ts @@ -1,4 +1,4 @@ -import { and, db, eq } from "@chatbotx.io/database/client" +import { and, db, eq, inArray } from "@chatbotx.io/database/client" import type { FolderType } from "@chatbotx.io/database/partials" import { conditionModel, webhookModel } from "@chatbotx.io/database/schema" import type { WebhookModel } from "@chatbotx.io/database/types" @@ -13,6 +13,10 @@ import { } from "../errors" import { folderService } from "../folder/service" import { assertPublicUrl } from "../net/ssrf-guard" +import { + type ConditionInput, + toConditionColumnsShared as toConditionColumns, +} from "../trigger/condition-columns" export const MAX_WEBHOOKS_PER_WORKSPACE = 100 const LOCK_TIMEOUT_SECONDS = 30 @@ -36,7 +40,10 @@ class WebhookService extends BaseService { * The builder create-webhook form's flow: unlike `register` (a full * webhook + conditions insert for a programmatic caller), this only * creates the row with an empty `url` — the URL is set by a later - * generate/regenerate step. + * generate/regenerate step. Do not consolidate the two: `register` takes + * a distributed lock and requires a valid public URL up front, while the + * builder's create-then-configure flow depends on this one accepting an + * empty URL. */ async create(input: { workspaceId: string @@ -161,6 +168,179 @@ class WebhookService extends BaseService { await this.audit("delete", `deleted webhook(s) (#${id})`) } + + /** Mirrors `triggerService.deleteMany` — bulk delete scoped to the workspace. */ + async deleteMany(input: { + workspaceId: string + ids: string[] + }): Promise { + const deletedWebhooks = await db.query.webhookModel.findMany({ + where: { workspaceId: input.workspaceId, id: { in: input.ids } }, + columns: { id: true }, + }) + + await db + .delete(webhookModel) + .where( + and( + eq(webhookModel.workspaceId, input.workspaceId), + inArray(webhookModel.id, input.ids), + ), + ) + + await removeWebhookCache(input.workspaceId) + + if (deletedWebhooks.length > 0) { + await this.audit( + "delete", + `deleted webhook${deletedWebhooks.length > 1 ? "s" : ""} (${deletedWebhooks.map((webhook) => `#${webhook.id}`).join(", ")})`, + ) + } + } + + /** + * Updates a webhook's `url` and diffs its `conditions` (delete/update/ + * create), in one transaction. Unlike `triggerService.updateWithConditions`, + * this updates ALL submitted conditions unconditionally (no `isSameJsonValue` + * diff) and ALWAYS refreshes the cache — do not factor the two into one + * shared helper, the behaviors are deliberately different. + */ + async updateWithConditions(input: { + workspaceId: string + id: string + url: string + conditions: ConditionInput[] + }): Promise { + const { workspaceId, id, url, conditions } = input + + const result = await db.transaction(async (tx) => { + const existingConditions = await tx.query.conditionModel.findMany({ + where: { + webhookId: id, + }, + }) + + const existingIds = new Set(existingConditions.map((c) => c.id)) + const submittedIds = new Set( + conditions.filter((c) => c.id).map((c) => c.id as string), + ) + + const conditionsToDelete = existingConditions.filter( + (existing) => !submittedIds.has(existing.id), + ) + + const conditionsToUpdate = conditions.filter( + (c) => c.id && existingIds.has(c.id as string), + ) + + const conditionsToCreate = conditions.filter((c) => !c.id) + + await tx + .update(webhookModel) + .set({ url }) + .where( + and( + eq(webhookModel.workspaceId, workspaceId), + eq(webhookModel.id, id), + ), + ) + + if (conditionsToDelete.length > 0) { + await tx.delete(conditionModel).where( + inArray( + conditionModel.id, + conditionsToDelete.map((c) => c.id), + ), + ) + } + + for (const condition of conditionsToUpdate) { + await tx + .update(conditionModel) + .set(toConditionColumns(condition)) + .where(eq(conditionModel.id, condition.id as string)) + } + + if (conditionsToCreate.length > 0) { + await tx.insert(conditionModel).values( + conditionsToCreate.map((c) => ({ + id: createId(), + webhookId: id, + ...toConditionColumns(c), + })), + ) + } + + return await tx.query.webhookModel.findFirst({ + where: { + id, + }, + }) + }) + + await updateWebhookCache(workspaceId) + + if (result) { + await this.audit("update", `updated a webhook (#${result.id})`) + } + + return result + } + + /** + * Applies a settings patch after diffing against the current row — a + * no-op submit returns early without writing or auditing. Keeps the same + * `enabled`/`disabled` vs `updated` audit branching as + * `triggerService.updateSettings`. + */ + async updateSettings(input: { + workspaceId: string + id: string + [key: string]: unknown + }): Promise { + const { workspaceId, id, ...patch } = input + + const webhook = await db.query.webhookModel.findFirst({ + where: { + id, + workspaceId, + }, + }) + + if (!webhook) { + throw notFoundException("Webhook not found") + } + + const changedEntries = Object.entries(patch).filter( + ([key, value]) => webhook[key as keyof typeof webhook] !== value, + ) + + if (changedEntries.length === 0) { + return + } + + const updated = await db + .update(webhookModel) + .set(patch) + .where(eq(webhookModel.id, webhook.id)) + .returning({ id: webhookModel.id }) + + if (updated.length === 0) { + return + } + + await updateWebhookCache(workspaceId) + + const changedKeys = changedEntries.map(([key]) => key) + let detail = `updated a webhook (#${webhook.id})` + if (changedKeys.length === 1 && changedKeys[0] === "active") { + detail = patch.active + ? `enabled a webhook (#${webhook.id})` + : `disabled a webhook (#${webhook.id})` + } + + await this.audit("update", detail) + } } export const webhookService = new WebhookService() diff --git a/packages/database/__tests__/broadcast-repository.test.ts b/packages/database/__tests__/broadcast-repository.test.ts new file mode 100644 index 0000000000..f84e2522ca --- /dev/null +++ b/packages/database/__tests__/broadcast-repository.test.ts @@ -0,0 +1,201 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + audienceFindMany: vi.fn(), + count: vi.fn(), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + broadcastModel: { + findMany: mocks.findMany, + findFirst: mocks.findFirst, + }, + contactsOnBroadcastsModel: { + findMany: mocks.audienceFindMany, + }, + }, + $count: mocks.count, + }, + eq: mocks.eq, + relationsFilterToSQL: vi.fn(() => "sql-filter"), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: { name: "broadcastModel.name" }, + contactsOnBroadcastsModel: { broadcastId: "broadcastId-column" }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + getPaginationWithDefaults: vi.fn(() => ({ limit: 10, offset: 0 })), + likeContains: vi.fn((value: string) => `%${value}%`), + parseOrderByAsObject: vi.fn(() => ({})), +})) + +const { broadcastRepository } = await import( + "../src/repositories/broadcast/repository" +) + +describe("broadcastRepository.listWithRelations", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("scopes to the workspace and excludes soft-deleted rows", async () => { + mocks.findMany.mockResolvedValue([{ id: "broadcast-1" }]) + + const result = await broadcastRepository.listWithRelations({ + workspaceId: "ws-1", + }) + + expect(result).toEqual([{ id: "broadcast-1" }]) + const call = mocks.findMany.mock.calls[0]?.[0] as { + where: { workspaceId: string; deletedAt: { isNull: boolean } } + with: { flow: unknown; integrationWhatsapp: unknown } + } + expect(call.where.workspaceId).toBe("ws-1") + expect(call.where.deletedAt).toEqual({ isNull: true }) + expect(call.with.flow).toBeDefined() + expect(call.with.integrationWhatsapp).toBeDefined() + }) + + test("passes the status filter through to the where clause", async () => { + mocks.findMany.mockResolvedValue([]) + + await broadcastRepository.listWithRelations({ + workspaceId: "ws-1", + status: "failed", + }) + + const call = mocks.findMany.mock.calls[0]?.[0] as { + where: { status?: string } + } + expect(call.where.status).toBe("failed") + }) + + test("omits status from the where clause when it is null", async () => { + mocks.findMany.mockResolvedValue([]) + + await broadcastRepository.listWithRelations({ + workspaceId: "ws-1", + status: null, + }) + + const call = mocks.findMany.mock.calls[0]?.[0] as { + where: { status?: string } + } + expect(call.where.status).toBeUndefined() + }) +}) + +describe("broadcastRepository.findIdIfActive", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("scopes to a non-deleted broadcast owned by the workspace", async () => { + mocks.findFirst.mockResolvedValue({ id: "broadcast-1" }) + + const result = await broadcastRepository.findIdIfActive({ + id: "broadcast-1", + workspaceId: "ws-1", + }) + + expect(result).toEqual({ id: "broadcast-1" }) + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: "broadcast-1", + workspaceId: "ws-1", + deletedAt: { isNull: true }, + }, + }), + ) + }) + + test("returns undefined when no row matches", async () => { + mocks.findFirst.mockResolvedValue(undefined) + + const result = await broadcastRepository.findIdIfActive({ + id: "missing", + workspaceId: "ws-1", + }) + + expect(result).toBeUndefined() + }) +}) + +describe("broadcastRepository.listAudience / countAudience", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("lists audience rows scoped to the broadcast id", async () => { + mocks.audienceFindMany.mockResolvedValue([{ contactId: "contact-1" }]) + + const result = await broadcastRepository.listAudience({ + broadcastId: "broadcast-1", + limit: 20, + offset: 0, + }) + + expect(result).toEqual([{ contactId: "contact-1" }]) + expect(mocks.audienceFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { broadcastId: "broadcast-1" }, + with: { contact: true }, + }), + ) + }) + + test("counts audience rows scoped to the broadcast id", async () => { + mocks.count.mockResolvedValue(3) + + const result = await broadcastRepository.countAudience("broadcast-1") + + expect(result).toBe(3) + expect(mocks.count).toHaveBeenCalled() + }) +}) + +describe("broadcastRepository.findByIdOrName", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("looks up by id when idOrName is numeric", async () => { + mocks.findFirst.mockResolvedValue({ id: "123" }) + + await broadcastRepository.findByIdOrName({ + workspaceId: "ws-1", + idOrName: "123", + }) + + const call = mocks.findFirst.mock.calls[0]?.[0] as { + where: { id?: string; name?: string; workspaceId: string } + } + expect(call.where.id).toBe("123") + expect(call.where.name).toBeUndefined() + }) + + test("looks up by name when idOrName is not numeric", async () => { + mocks.findFirst.mockResolvedValue({ name: "My Broadcast" }) + + await broadcastRepository.findByIdOrName({ + workspaceId: "ws-1", + idOrName: "My Broadcast", + }) + + const call = mocks.findFirst.mock.calls[0]?.[0] as { + where: { id?: string; name?: string; workspaceId: string } + } + expect(call.where.name).toBe("My Broadcast") + expect(call.where.id).toBeUndefined() + }) +}) diff --git a/packages/database/__tests__/flow-repository.test.ts b/packages/database/__tests__/flow-repository.test.ts new file mode 100644 index 0000000000..f44ad00932 --- /dev/null +++ b/packages/database/__tests__/flow-repository.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + flowModel: { + findMany: mocks.findMany, + findFirst: mocks.findFirst, + }, + }, + $count: mocks.count, + }, + relationsFilterToSQL: vi.fn(() => "sql-filter"), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + rootFolderId: "0", +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + flowModel: { name: "flowModel.name" }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + likeContains: vi.fn((value: string) => `%${value}%`), + parseOrderByAsObject: vi.fn(() => ({})), + parsePagination: vi.fn(() => ({ limit: 10, offset: 0 })), +})) + +const { flowRepository } = await import("../src/repositories/flow/repository") + +describe("flowRepository.listWithVersions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("scopes the query to the workspace and attaches draft+latest versions", async () => { + mocks.findMany.mockResolvedValue([{ id: "flow-1" }]) + + const result = await flowRepository.listWithVersions({ + workspaceId: "ws-1", + }) + + expect(result).toEqual([{ id: "flow-1" }]) + const call = mocks.findMany.mock.calls[0]?.[0] as { + where: { workspaceId: string } + with: { flowVersions: { where: { OR: unknown[] } } } + } + expect(call.where.workspaceId).toBe("ws-1") + expect(call.with.flowVersions.where.OR).toEqual([ + { isDraft: true }, + { isLatest: true }, + ]) + }) + + test("resolves the root-folder sentinel to isNull", async () => { + mocks.findMany.mockResolvedValue([]) + + await flowRepository.listWithVersions({ + workspaceId: "ws-1", + folderId: "0", + }) + + const call = mocks.findMany.mock.calls[0]?.[0] as { + where: { folderId: unknown } + } + expect(call.where.folderId).toEqual({ isNull: true }) + }) +}) + +describe("flowRepository.count", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("delegates to db.$count with the same where shape", async () => { + mocks.count.mockResolvedValue(5) + + const result = await flowRepository.count({ workspaceId: "ws-1" }) + + expect(result).toBe(5) + expect(mocks.count).toHaveBeenCalled() + }) +}) + +describe("flowRepository.findWithVersions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("scopes by both id and workspaceId", async () => { + mocks.findFirst.mockResolvedValue({ id: "flow-1" }) + + const result = await flowRepository.findWithVersions({ + workspaceId: "ws-1", + id: "flow-1", + }) + + expect(result).toEqual({ id: "flow-1" }) + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { workspaceId: "ws-1", id: "flow-1" }, + with: { flowVersions: true }, + }), + ) + }) +}) + +describe("flowRepository.listIdsByIds", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns only the ids that exist in the workspace", async () => { + mocks.findMany.mockResolvedValue([{ id: "flow-1" }, { id: "flow-2" }]) + + const result = await flowRepository.listIdsByIds({ + workspaceId: "ws-1", + ids: ["flow-1", "flow-2", "flow-missing"], + }) + + expect(result).toEqual(["flow-1", "flow-2"]) + }) +}) diff --git a/packages/database/__tests__/template-selectable-resource-repository.test.ts b/packages/database/__tests__/template-selectable-resource-repository.test.ts new file mode 100644 index 0000000000..8d7dfaad9e --- /dev/null +++ b/packages/database/__tests__/template-selectable-resource-repository.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + flowFindMany: vi.fn(), + automatedResponseFindMany: vi.fn(), + savedReplyFindMany: vi.fn(), + botFieldFindMany: vi.fn(), + count: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + flowModel: { findMany: mocks.flowFindMany }, + tagModel: { findMany: vi.fn() }, + customFieldModel: { findMany: vi.fn() }, + productModel: { findMany: vi.fn() }, + aiFunctionModel: { findMany: vi.fn() }, + aiAgentModel: { findMany: vi.fn() }, + appointmentCalendarModel: { findMany: vi.fn() }, + integrationWebchatModel: { findMany: vi.fn() }, + triggerModel: { findMany: vi.fn() }, + fbCommentAutomationModel: { findMany: vi.fn() }, + reflinkModel: { findMany: vi.fn() }, + automatedResponseModel: { findMany: mocks.automatedResponseFindMany }, + savedReplyModel: { findMany: mocks.savedReplyFindMany }, + botFieldModel: { findMany: mocks.botFieldFindMany }, + }, + $count: mocks.count, + }, + relationsFilterToSQL: vi.fn(() => "sql-filter"), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + aiAgentModel: {}, + aiFunctionModel: {}, + appointmentCalendarModel: {}, + automatedResponseModel: {}, + botFieldModel: {}, + customFieldModel: {}, + fbCommentAutomationModel: {}, + flowModel: {}, + integrationWebchatModel: {}, + productModel: {}, + reflinkModel: {}, + savedReplyModel: {}, + tagModel: {}, + triggerModel: {}, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + likeContains: vi.fn((value: string) => `%${value}%`), +})) + +const { templateSelectableResourceRepository } = await import( + "../src/repositories/template-selectable-resource/repository" +) + +describe("templateSelectableResourceRepository.listFlows", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns rows, total, and allIds when under the cap", async () => { + mocks.flowFindMany + .mockResolvedValueOnce([{ id: "flow-1", name: "Flow 1" }]) + .mockResolvedValueOnce([{ id: "flow-1" }]) + mocks.count.mockResolvedValue(1) + + const result = await templateSelectableResourceRepository.listFlows({ + workspaceId: "ws-1", + offset: 0, + limit: 100, + }) + + expect(result.rows).toEqual([{ id: "flow-1", name: "Flow 1" }]) + expect(result.total).toBe(1) + expect(result.allIds).toEqual(["flow-1"]) + }) + + test("omits allIds when offset is not 0", async () => { + mocks.flowFindMany.mockResolvedValueOnce([]) + mocks.count.mockResolvedValue(500) + + const result = await templateSelectableResourceRepository.listFlows({ + workspaceId: "ws-1", + offset: 100, + limit: 100, + }) + + expect(result.allIds).toBeUndefined() + // Only the page query ran, not the allIds query. + expect(mocks.flowFindMany).toHaveBeenCalledTimes(1) + }) +}) + +describe("templateSelectableResourceRepository.listKeywords", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("scopes to inbound type and labels rows via text, falling back to keywords", async () => { + mocks.automatedResponseFindMany + .mockResolvedValueOnce([ + { id: "kw-1", text: "hello", keywords: ["hi"] }, + { id: "kw-2", text: null, keywords: ["bye", "later"] }, + { id: "kw-3", text: " ", keywords: [] }, + ]) + .mockResolvedValueOnce([{ id: "kw-1" }, { id: "kw-2" }, { id: "kw-3" }]) + mocks.count.mockResolvedValue(3) + + const result = await templateSelectableResourceRepository.listKeywords({ + workspaceId: "ws-1", + offset: 0, + limit: 100, + }) + + expect(result.rows).toEqual([ + { id: "kw-1", name: "hello" }, + { id: "kw-2", name: "bye, later" }, + { id: "kw-3", name: "(untitled)" }, + ]) + + const call = mocks.automatedResponseFindMany.mock.calls[0]?.[0] as { + where: { type: string } + } + expect(call.where.type).toBe("inbound") + }) +}) + +describe("templateSelectableResourceRepository.listSettings", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns the two raw arrays scoped to the workspace", async () => { + mocks.savedReplyFindMany.mockResolvedValue([ + { id: "sr-1", shortcut: "/hello" }, + ]) + mocks.botFieldFindMany.mockResolvedValue([{ id: "bf-1", name: "Age" }]) + + const result = + await templateSelectableResourceRepository.listSettings("ws-1") + + expect(result).toEqual({ + savedReplies: [{ id: "sr-1", shortcut: "/hello" }], + botFields: [{ id: "bf-1", name: "Age" }], + }) + expect(mocks.savedReplyFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { workspaceId: "ws-1" } }), + ) + }) +}) diff --git a/packages/database/__tests__/trigger-repository.test.ts b/packages/database/__tests__/trigger-repository.test.ts new file mode 100644 index 0000000000..05fd5cf9c6 --- /dev/null +++ b/packages/database/__tests__/trigger-repository.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => { + const selectWhereLimitOffset = { + limit: vi.fn(), + } + const selectWhere = { + limit: vi.fn(() => selectWhereLimitOffset), + where: vi.fn(), + } + const selectFrom = { + where: vi.fn(() => selectWhere), + } + const select = vi.fn(() => ({ from: vi.fn(() => selectFrom) })) + return { + select, + selectFrom, + selectWhere, + selectWhereLimitOffset, + findFirst: vi.fn(), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + count: vi.fn(() => "count-expr"), + db: { + select: mocks.select, + query: { + triggerModel: { findFirst: mocks.findFirst }, + }, + }, + eq: vi.fn((field: unknown, value: unknown) => ({ eq: [field, value] })), + isNull: vi.fn((field: unknown) => ({ isNull: field })), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + triggerModel: { workspaceId: "workspaceId-col", folderId: "folderId-col" }, +})) + +const { triggerRepository } = await import( + "../src/repositories/trigger/repository" +) + +describe("triggerRepository.listPaginated", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("resolves an empty-string folderId to isNull (trigger sentinel, not rootFolderId)", async () => { + const rows = [{ id: "trigger-1" }] + const offsetFn = vi.fn().mockResolvedValue(rows) + mocks.selectWhere.limit.mockReturnValue({ offset: offsetFn }) + const countBuilder = { where: vi.fn().mockResolvedValue([{ count: 1 }]) } + mocks.select + .mockReturnValueOnce({ + from: vi.fn(() => ({ where: vi.fn(() => mocks.selectWhere) })), + }) + .mockReturnValueOnce({ + from: vi.fn(() => countBuilder), + }) + + const result = await triggerRepository.listPaginated({ + workspaceId: "ws-1", + folderId: "", + limit: 10, + offset: 0, + }) + + expect(result.rows).toEqual(rows) + expect(result.total).toBe(1) + }) +}) + +describe("triggerRepository.findWithConditions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns null when neither id nor workspaceId is provided", async () => { + const result = await triggerRepository.findWithConditions({}) + expect(result).toBeNull() + expect(mocks.findFirst).not.toHaveBeenCalled() + }) + + test("queries with conditions included when id is provided", async () => { + mocks.findFirst.mockResolvedValue({ id: "trigger-1", conditions: [] }) + + const result = await triggerRepository.findWithConditions({ + id: "trigger-1", + }) + + expect(result).toEqual({ id: "trigger-1", conditions: [] }) + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "trigger-1" }, + with: { conditions: true }, + }), + ) + }) + + test("returns null when no row matches", async () => { + mocks.findFirst.mockResolvedValue(undefined) + + const result = await triggerRepository.findWithConditions({ + workspaceId: "ws-1", + }) + + expect(result).toBeNull() + }) +}) diff --git a/packages/database/src/repositories/broadcast/index.ts b/packages/database/src/repositories/broadcast/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/broadcast/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/broadcast/repository.ts b/packages/database/src/repositories/broadcast/repository.ts new file mode 100644 index 0000000000..e0229375e0 --- /dev/null +++ b/packages/database/src/repositories/broadcast/repository.ts @@ -0,0 +1,150 @@ +import { type DatabaseClient, db, eq, relationsFilterToSQL } from "../../client" +import { broadcastModel, contactsOnBroadcastsModel } from "../../schema" +import { + getPaginationWithDefaults, + likeContains, + parseOrderByAsObject, +} from "../../utils" + +const NUMERIC_RE = /^\d+$/ + +export type BroadcastListInput = { + workspaceId: string + name?: string | null + /** The builder narrows this to its own `BroadcastFilterStatus` union. */ + status?: string | null + page?: number | null + perPage?: number | null + sort?: { id: string; desc: boolean }[] | null +} + +const buildWhere = (input: BroadcastListInput) => ({ + workspaceId: input.workspaceId, + name: input.name ? { ilike: likeContains(input.name) } : undefined, + status: input.status ?? undefined, + deletedAt: { isNull: true as const }, +}) + +export const broadcastRepository = { + /** + * Paginated broadcast list with the 3 slim relations the list page shows. + * The `with` literal stays inline for Drizzle's type inference to survive + * into `BroadcastResourceWithRelations`. + */ + async listWithRelations(input: BroadcastListInput, tx: DatabaseClient = db) { + const where = buildWhere(input) + const pagination = getPaginationWithDefaults(input) + const orderBy = parseOrderByAsObject(broadcastModel, input) + + return await tx.query.broadcastModel.findMany({ + where, + with: { + flow: { + columns: { + id: true, + name: true, + }, + }, + integrationWhatsapp: { + columns: { + id: true, + name: true, + }, + }, + integrationMessenger: { + columns: { + id: true, + name: true, + }, + }, + }, + ...pagination, + orderBy, + }) + }, + + async count( + input: BroadcastListInput, + tx: DatabaseClient = db, + ): Promise { + const where = buildWhere(input) + return await tx.$count( + broadcastModel, + relationsFilterToSQL(broadcastModel, where), + ) + }, + + /** + * Ownership gate before listing a broadcast's audience — scoped to a + * non-deleted broadcast owned by this workspace so a soft-deleted (or + * foreign) broadcast never leaks its audience, even if a future caller + * skips the `publicGetBroadcast` lookup the current API handler happens to + * run first. + */ + async findIdIfActive( + input: { id: string; workspaceId: string }, + tx: DatabaseClient = db, + ): Promise<{ id: string } | undefined> { + return await tx.query.broadcastModel.findFirst({ + where: { + id: input.id, + workspaceId: input.workspaceId, + deletedAt: { isNull: true }, + }, + columns: { id: true }, + }) + }, + + async listAudience( + input: { broadcastId: string; limit: number; offset: number }, + tx: DatabaseClient = db, + ) { + return await tx.query.contactsOnBroadcastsModel.findMany({ + where: { broadcastId: input.broadcastId }, + with: { contact: true }, + limit: input.limit, + offset: input.offset, + }) + }, + + async countAudience( + broadcastId: string, + tx: DatabaseClient = db, + ): Promise { + return await tx.$count( + contactsOnBroadcastsModel, + eq(contactsOnBroadcastsModel.broadcastId, broadcastId), + ) + }, + + /** id-or-name lookup, scoped to a non-deleted broadcast in the workspace. */ + async findByIdOrName( + input: { workspaceId: string; idOrName: string }, + tx: DatabaseClient = db, + ) { + const where = { + ...(NUMERIC_RE.test(input.idOrName) + ? { id: input.idOrName, workspaceId: input.workspaceId } + : { name: input.idOrName, workspaceId: input.workspaceId }), + deletedAt: { isNull: true as const }, + } + + return await tx.query.broadcastModel.findFirst({ where }) + }, + + /** + * Reads only the stored `contactFilter` of a broadcast — used by the + * resend action to re-derive the pruned filter with the CURRENT caller's + * email/phone visibility, rather than trusting whatever was pruned into + * the original broadcast. + */ + async findContactFilter( + input: { id: string; workspaceId: string }, + tx: DatabaseClient = db, + ): Promise<{ contactFilter: unknown } | undefined> { + return await tx.query.broadcastModel.findFirst({ + where: { id: input.id, workspaceId: input.workspaceId }, + columns: { contactFilter: true }, + }) + }, +} diff --git a/packages/database/src/repositories/condition/index.ts b/packages/database/src/repositories/condition/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/condition/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/condition/repository.ts b/packages/database/src/repositories/condition/repository.ts new file mode 100644 index 0000000000..e0b2f532b1 --- /dev/null +++ b/packages/database/src/repositories/condition/repository.ts @@ -0,0 +1,21 @@ +import { type DatabaseClient, db } from "../../client" + +export const conditionRepository = { + async listByTriggerIds(triggerIds: string[], tx: DatabaseClient = db) { + if (triggerIds.length === 0) { + return [] + } + return await tx.query.conditionModel.findMany({ + where: { triggerId: { in: triggerIds } }, + }) + }, + + async listByWebhookIds(webhookIds: string[], tx: DatabaseClient = db) { + if (webhookIds.length === 0) { + return [] + } + return await tx.query.conditionModel.findMany({ + where: { webhookId: { in: webhookIds } }, + }) + }, +} diff --git a/packages/database/src/repositories/flow/index.ts b/packages/database/src/repositories/flow/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/flow/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/flow/repository.ts b/packages/database/src/repositories/flow/repository.ts new file mode 100644 index 0000000000..95d4c31795 --- /dev/null +++ b/packages/database/src/repositories/flow/repository.ts @@ -0,0 +1,92 @@ +import { type DatabaseClient, db, relationsFilterToSQL } from "../../client" +import { rootFolderId } from "../../partials" +import { flowModel } from "../../schema" +import { + likeContains, + parseOrderByAsObject, + parsePagination, +} from "../../utils" + +export type FlowListInput = { + workspaceId: string + name?: string | null + folderId?: string | null + active?: boolean | null + page?: number | null + perPage?: number | null + sort?: { id: string; desc: boolean }[] | null +} + +const buildWhere = (input: FlowListInput) => ({ + workspaceId: input.workspaceId, + folderId: input.folderId + ? // biome-ignore lint/style/noNestedTernary: mirrors the original builder query verbatim + input.folderId === rootFolderId + ? { isNull: true as const } + : input.folderId + : undefined, + name: input.name ? { ilike: likeContains(input.name) } : undefined, + active: input.active === null ? undefined : (input.active ?? undefined), +}) + +export const flowRepository = { + /** + * Paginated flow list with each row's draft + latest version attached. + * The `with` literal stays inline so Drizzle's relational-query type + * inference survives into `ListFlowsResponse` — do not hoist it out. + */ + async listWithVersions(input: FlowListInput, tx: DatabaseClient = db) { + const where = buildWhere(input) + const pagination = parsePagination(input) + const orderBy = parseOrderByAsObject(flowModel, input) + + return await tx.query.flowModel.findMany({ + where, + orderBy, + ...pagination, + with: { + flowVersions: { + where: { + OR: [{ isDraft: true }, { isLatest: true }], + }, + }, + }, + }) + }, + + async count(input: FlowListInput, tx: DatabaseClient = db): Promise { + const where = buildWhere(input) + return await tx.$count(flowModel, relationsFilterToSQL(flowModel, where)) + }, + + /** Flow detail with all versions — shared by both flow detail pages. */ + async findWithVersions( + input: { workspaceId: string; id: string }, + tx: DatabaseClient = db, + ) { + return await tx.query.flowModel.findFirst({ + where: { + workspaceId: input.workspaceId, + id: input.id, + }, + with: { + flowVersions: true, + }, + }) + }, + + /** Existence check for a set of flow ids, scoped to the workspace. */ + async listIdsByIds( + input: { workspaceId: string; ids: string[] }, + tx: DatabaseClient = db, + ): Promise { + const rows = await tx.query.flowModel.findMany({ + where: { + workspaceId: input.workspaceId, + id: { in: input.ids }, + }, + columns: { id: true }, + }) + return rows.map((row) => row.id) + }, +} diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index 2e407e55dd..104f4d023d 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -9,8 +9,10 @@ export * from "./appointment-calendar" export * from "./appointment-reminder-dispatch" export * from "./auth-account" export * from "./automation-throttle" +export * from "./broadcast" export * from "./broadcast-purge" export * from "./coexist-sync-run" +export * from "./condition" export * from "./contact" export * from "./contact-custom-field" export * from "./contact-inbox" @@ -18,6 +20,7 @@ export * from "./conversation-ai-context" export * from "./coupon" export * from "./error-log" export * from "./file" +export * from "./flow" export * from "./import" export * from "./inbox" export * from "./integration-api" @@ -34,10 +37,14 @@ export * from "./meta-capi-event" export * from "./meta-catalog-item" export * from "./product" export * from "./product-category" +export * from "./sequence" +export * from "./template-selectable-resource" +export * from "./trigger" export * from "./user-persistent-menu" export * from "./webhook" export * from "./webhook-execution" export * from "./whatsapp-business-account" export * from "./whatsapp-coexist-staging" export * from "./whatsapp-flow" +export * from "./whatsapp-message-template" export * from "./workspace-api-token" diff --git a/packages/database/src/repositories/sequence/index.ts b/packages/database/src/repositories/sequence/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/sequence/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/sequence/repository.ts b/packages/database/src/repositories/sequence/repository.ts new file mode 100644 index 0000000000..b0e37e7369 --- /dev/null +++ b/packages/database/src/repositories/sequence/repository.ts @@ -0,0 +1,106 @@ +import { type DatabaseClient, db, eq, relationsFilterToSQL } from "../../client" +import { rootFolderId } from "../../partials" +import { + contactsOnSequenceModel, + sequenceModel, + sequenceStepModel, +} from "../../schema" +import { + getPaginationWithDefaults, + likeContains, + parseOrderByAsObject, +} from "../../utils" + +export type SequenceListInput = { + workspaceId: string + name?: string | null + folderId?: string | null + active?: boolean | null + page?: number | null + perPage?: number | null + sort?: { id: string; desc: boolean }[] | null +} + +const buildWhere = (input: SequenceListInput) => { + let folderIdFilter: string | { isNull: true } | undefined + if (input.folderId) { + folderIdFilter = + input.folderId === rootFolderId + ? { isNull: true as const } + : input.folderId + } + + return { + workspaceId: input.workspaceId, + folderId: folderIdFilter, + name: input.name ? { ilike: likeContains(input.name) } : undefined, + active: + input.active !== undefined && input.active !== null + ? input.active + : undefined, + } +} + +export const sequenceRepository = { + /** + * Paginated sequences with per-row `stepsCount`/`subscribersCount`. The + * `extras` closures reference `db.$count` directly, so they must stay + * inside this method for Drizzle's type inference to survive into + * `ListSequencesResponse`. + */ + async listWithCounts(input: SequenceListInput, tx: DatabaseClient = db) { + const where = buildWhere(input) + const pagination = getPaginationWithDefaults(input) + const orderBy = parseOrderByAsObject(sequenceModel, input) + + return await tx.query.sequenceModel.findMany({ + where, + orderBy, + ...pagination, + extras: { + stepsCount: (table) => + db.$count( + sequenceStepModel, + eq(sequenceStepModel.sequenceId, table.id), + ), + subscribersCount: (table) => + db.$count( + contactsOnSequenceModel, + eq(contactsOnSequenceModel.sequenceId, table.id), + ), + }, + }) + }, + + async count( + input: SequenceListInput, + tx: DatabaseClient = db, + ): Promise { + const where = buildWhere(input) + return await tx.$count( + sequenceModel, + relationsFilterToSQL(sequenceModel, where), + ) + }, + + /** Sequence detail with ordered steps + each step's flow. */ + async findWithSteps( + input: { workspaceId: string; id: string }, + tx: DatabaseClient = db, + ) { + return await tx.query.sequenceModel.findFirst({ + where: { + id: input.id, + workspaceId: input.workspaceId, + }, + with: { + sequenceSteps: { + with: { + flow: true, + }, + orderBy: (step, { asc }) => [asc(step.order)], + }, + }, + }) + }, +} diff --git a/packages/database/src/repositories/template-selectable-resource/index.ts b/packages/database/src/repositories/template-selectable-resource/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/template-selectable-resource/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/template-selectable-resource/repository.ts b/packages/database/src/repositories/template-selectable-resource/repository.ts new file mode 100644 index 0000000000..4399f9036e --- /dev/null +++ b/packages/database/src/repositories/template-selectable-resource/repository.ts @@ -0,0 +1,496 @@ +import { type DatabaseClient, db, relationsFilterToSQL } from "../../client" +import { + aiAgentModel, + aiFunctionModel, + appointmentCalendarModel, + automatedResponseModel, + customFieldModel, + fbCommentAutomationModel, + flowModel, + integrationWebchatModel, + productModel, + reflinkModel, + tagModel, + triggerModel, +} from "../../schema" +import { likeContains } from "../../utils" + +const ALL_IDS_CAP = 1000 + +export type SelectableResourceRow = { + id: string + name: string +} + +export type ListSelectableResourceRowsResult = { + rows: SelectableResourceRow[] + total: number + allIds?: string[] +} + +const buildAllIds = async ( + offset: number, + total: number, + findAllIds: () => Promise, +): Promise => + offset === 0 && total <= ALL_IDS_CAP ? await findAllIds() : undefined + +type CategoryInput = { + workspaceId: string + keyword?: string | null + offset: number + limit: number +} + +export const templateSelectableResourceRepository = { + async listFlows( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.flowModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(flowModel, relationsFilterToSQL(flowModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + (await tx.query.flowModel.findMany({ where, columns: { id: true } })).map( + (row) => row.id, + ), + ) + + return { rows, total, allIds } + }, + + async listTags( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + deletedAt: { isNull: true as const }, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.tagModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(tagModel, relationsFilterToSQL(tagModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + (await tx.query.tagModel.findMany({ where, columns: { id: true } })).map( + (row) => row.id, + ), + ) + + return { rows, total, allIds } + }, + + async listCustomFields( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.customFieldModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count( + customFieldModel, + relationsFilterToSQL(customFieldModel, where), + ), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.customFieldModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listProducts( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.productModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(productModel, relationsFilterToSQL(productModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.productModel.findMany({ where, columns: { id: true } }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listAIFunctions( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.aiFunctionModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(aiFunctionModel, relationsFilterToSQL(aiFunctionModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.aiFunctionModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listAIAgents( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.aiAgentModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(aiAgentModel, relationsFilterToSQL(aiAgentModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.aiAgentModel.findMany({ where, columns: { id: true } }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listCalendars( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + deletedAt: { isNull: true as const }, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.appointmentCalendarModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count( + appointmentCalendarModel, + relationsFilterToSQL(appointmentCalendarModel, where), + ), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.appointmentCalendarModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listWebchats( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.integrationWebchatModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count( + integrationWebchatModel, + relationsFilterToSQL(integrationWebchatModel, where), + ), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.integrationWebchatModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listTriggers( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.triggerModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(triggerModel, relationsFilterToSQL(triggerModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.triggerModel.findMany({ where, columns: { id: true } }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listFbCommentAutomations( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.fbCommentAutomationModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count( + fbCommentAutomationModel, + relationsFilterToSQL(fbCommentAutomationModel, where), + ), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.fbCommentAutomationModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + async listEntryPointLinks( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + name: keyword ? { ilike: likeContains(keyword) } : undefined, + } + + const [rows, total] = await Promise.all([ + tx.query.reflinkModel.findMany({ + where, + columns: { id: true, name: true }, + limit, + offset, + orderBy: { name: "asc" }, + }), + tx.$count(reflinkModel, relationsFilterToSQL(reflinkModel, where)), + ]) + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.reflinkModel.findMany({ where, columns: { id: true } }) + ).map((row) => row.id), + ) + + return { rows, total, allIds } + }, + + /** + * `AutomatedResponse` (Keywords) has no `name` column — inbound rows are + * keyed by their `keywords` array and outbound rows by `text` — so the + * picker label falls back through `text`, then the joined keyword list. + * Search is done in the database on `keywords`/`text` directly rather than + * post-filtering in memory, so pagination stays exact under a search term. + * + * "keywords" is the inbound half of `AutomatedResponse` — the outbound + * half backs the unrelated "Page Automated Responses" comment-automation + * feature, which has no export category of its own. Without this filter, + * the picker would list a workspace's outbound rows under "Keywords" too. + */ + async listKeywords( + input: CategoryInput, + tx: DatabaseClient = db, + ): Promise { + const { workspaceId, keyword, offset, limit } = input + const where = { + workspaceId, + type: "inbound" as const, + ...(keyword + ? { + OR: [ + { text: { ilike: likeContains(keyword) } }, + { keywords: { arrayContains: [keyword] } }, + ], + } + : {}), + } + + const [rows, total] = await Promise.all([ + tx.query.automatedResponseModel.findMany({ + where, + columns: { id: true, text: true, keywords: true }, + limit, + offset, + orderBy: { createdAt: "desc" }, + }), + tx.$count( + automatedResponseModel, + relationsFilterToSQL(automatedResponseModel, where), + ), + ]) + + const toLabel = (row: { + text: string | null + keywords: string[] + }): string => row.text?.trim() || row.keywords.join(", ") || "(untitled)" + + const allIds = await buildAllIds(offset, total, async () => + ( + await tx.query.automatedResponseModel.findMany({ + where, + columns: { id: true }, + }) + ).map((row) => row.id), + ) + + return { + rows: rows.map((row) => ({ id: row.id, name: toLabel(row) })), + total, + allIds, + } + }, + + /** + * `settings` bundles two tables (`SavedReply`, `BotField`) under one + * category, mirroring `settingsAdapter`'s two-kind entries. Returns the two + * raw arrays — the in-memory merge/sort/filter/paginate is presentation + * logic and stays in the builder query, not here. + */ + async listSettings( + workspaceId: string, + tx: DatabaseClient = db, + ): Promise<{ + savedReplies: { id: string; shortcut: string }[] + botFields: { id: string; name: string }[] + }> { + const [savedReplies, botFields] = await Promise.all([ + tx.query.savedReplyModel.findMany({ + where: { workspaceId }, + columns: { id: true, shortcut: true }, + }), + tx.query.botFieldModel.findMany({ + where: { workspaceId }, + columns: { id: true, name: true }, + }), + ]) + + return { savedReplies, botFields } + }, +} diff --git a/packages/database/src/repositories/trigger/index.ts b/packages/database/src/repositories/trigger/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/trigger/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/trigger/repository.ts b/packages/database/src/repositories/trigger/repository.ts new file mode 100644 index 0000000000..551f0279f7 --- /dev/null +++ b/packages/database/src/repositories/trigger/repository.ts @@ -0,0 +1,86 @@ +import { and, count, type DatabaseClient, db, eq, isNull } from "../../client" +import { triggerModel } from "../../schema" + +const buildWhere = (input: { + workspaceId: string + folderId?: string | null + name?: string +}) => { + const conditions = [eq(triggerModel.workspaceId, input.workspaceId)] + + if (input.folderId !== undefined) { + const folderId = + input.folderId === null || input.folderId === "" ? null : input.folderId + if (folderId === null) { + conditions.push(isNull(triggerModel.folderId)) + } else { + conditions.push(eq(triggerModel.folderId, folderId)) + } + } + + if (input.name) { + conditions.push(eq(triggerModel.name, input.name)) + } + + return and(...conditions) +} + +export const triggerRepository = { + /** + * Paginated trigger rows, SQL-builder style — preserves the exact + * `folderId === null || ""` → `isNull` semantics that triggers use (unlike + * webhooks, which use `rootFolderId`). Do not unify the two. + */ + async listPaginated( + input: { + workspaceId: string + folderId?: string | null + name?: string + limit: number + offset: number + }, + tx: DatabaseClient = db, + ) { + const whereClause = buildWhere(input) + + const [rows, countResult] = await Promise.all([ + tx + .select() + .from(triggerModel) + .where(whereClause) + .limit(input.limit) + .offset(input.offset), + tx.select({ count: count() }).from(triggerModel).where(whereClause), + ]) + + return { rows, total: countResult[0]?.count ?? 0 } + }, + + async findWithConditions( + params: { id?: string; workspaceId?: string }, + tx: DatabaseClient = db, + ) { + const where: Record = {} + + if (params.id) { + where.id = params.id + } + + if (params.workspaceId) { + where.workspaceId = params.workspaceId + } + + if (Object.keys(where).length === 0) { + return null + } + + const result = await tx.query.triggerModel.findFirst({ + where, + with: { + conditions: true, + }, + }) + + return result ?? null + }, +} diff --git a/packages/database/src/repositories/webhook/index.ts b/packages/database/src/repositories/webhook/index.ts index f771da4e66..c4fc6f2db4 100644 --- a/packages/database/src/repositories/webhook/index.ts +++ b/packages/database/src/repositories/webhook/index.ts @@ -4,6 +4,8 @@ export type { DateTimeWebhookConditionRow, } from "./repository" export { + findWebhookWithConditions, listActiveDateTimeWebhooks, listContactCustomFieldsForDateTimeSweep, + listWebhooksPaginated, } from "./repository" diff --git a/packages/database/src/repositories/webhook/repository.ts b/packages/database/src/repositories/webhook/repository.ts index fa17786042..465ce06af5 100644 --- a/packages/database/src/repositories/webhook/repository.ts +++ b/packages/database/src/repositories/webhook/repository.ts @@ -1,5 +1,7 @@ -import { db } from "../../client" +import { and, count, type DatabaseClient, db, eq, isNull } from "../../client" +import { rootFolderId } from "../../partials" import { triggerEventTypes } from "../../partials/trigger" +import { webhookModel } from "../../schema" export type { DateTimeContactCustomFieldRow } from "../contact-custom-field" export { listContactCustomFieldsForDateTimeSweep } from "../contact-custom-field" @@ -57,3 +59,79 @@ export async function listActiveDateTimeWebhooks(params: { : undefined, } } + +/** + * Paginated webhook rows, SQL-builder style. Webhooks use `rootFolderId` as + * the root-folder sentinel (unlike triggers, which use `""`) — preserve each + * verbatim, do not unify them. + */ +export async function listWebhooksPaginated( + input: { + workspaceId: string + folderId?: string | null + name?: string + limit: number + offset: number + }, + tx: DatabaseClient = db, +): Promise<{ rows: (typeof webhookModel.$inferSelect)[]; total: number }> { + const conditions = [eq(webhookModel.workspaceId, input.workspaceId)] + + if (input.folderId !== undefined) { + const folderId = + input.folderId === null || input.folderId === rootFolderId + ? null + : input.folderId + if (folderId === null) { + conditions.push(isNull(webhookModel.folderId)) + } else { + conditions.push(eq(webhookModel.folderId, folderId)) + } + } + + if (input.name) { + conditions.push(eq(webhookModel.name, input.name)) + } + + const whereClause = and(...conditions) + + const [rows, countResult] = await Promise.all([ + tx + .select() + .from(webhookModel) + .where(whereClause) + .limit(input.limit) + .offset(input.offset), + tx.select({ count: count() }).from(webhookModel).where(whereClause), + ]) + + return { rows, total: countResult[0]?.count ?? 0 } +} + +export async function findWebhookWithConditions( + params: { id?: string; workspaceId?: string }, + tx: DatabaseClient = db, +) { + const where: Record = {} + + if (params.id) { + where.id = params.id + } + + if (params.workspaceId) { + where.workspaceId = params.workspaceId + } + + if (Object.keys(where).length === 0) { + return null + } + + const result = await tx.query.webhookModel.findFirst({ + where, + with: { + conditions: true, + }, + }) + + return result ?? null +} diff --git a/packages/database/src/repositories/whatsapp-message-template/index.ts b/packages/database/src/repositories/whatsapp-message-template/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/whatsapp-message-template/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/whatsapp-message-template/repository.ts b/packages/database/src/repositories/whatsapp-message-template/repository.ts new file mode 100644 index 0000000000..9662f3e3da --- /dev/null +++ b/packages/database/src/repositories/whatsapp-message-template/repository.ts @@ -0,0 +1,20 @@ +import { type DatabaseClient, db } from "../../client" + +export const whatsappMessageTemplateRepository = { + /** + * Template ids for a WhatsApp integration, used to filter flows by their + * start-step template (`sendWaTemplateMessage`). Kept minimal and + * read-only — the meta-channels scope owns `syncForIntegration` and other + * write paths for this table. + */ + async listIdsByIntegration( + input: { integrationWhatsappId: string }, + tx: DatabaseClient = db, + ): Promise { + const templates = await tx.query.whatsappMessageTemplateModel.findMany({ + where: { integrationWhatsappId: input.integrationWhatsappId }, + columns: { id: true }, + }) + return templates.map((t) => t.id) + }, +} From 3c47f71a4a1fdfb5f033cd7bd3f3cc99dce6dcd3 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 12:24:37 +0700 Subject: [PATCH 2/8] feat(automation): widen public API to full CRUD across flows, triggers, keywords, ai-agents, reflinks, ai-triggers Finishes the data-access chain (action -> service -> repository -> DB) for flows, sequences, broadcasts, saved-replies, reflinks, bot-fields, and greenfield ai-triggers, then widens the `automation` token scope from read-only to full CRUD so MCP/agent clients can build, publish, and inspect automations end to end. --- .../public-spec-operations.test.ts.snap | 165 +++++++++ .../__tests__/ai-agents-public-api.test.ts | 201 ++++++++++ .../__tests__/ai-triggers-public-api.test.ts | 201 ++++++++++ .../__tests__/flows-public-api.test.ts | 343 ++++++++++++++++++ .../__tests__/keywords-public-api.test.ts | 241 ++++++++++++ .../__tests__/reflinks-public-api.test.ts | 183 ++++++++++ .../__tests__/triggers-public-api.test.ts | 269 ++++++++++++++ .../__tests__/update-broadcast.action.test.ts | 164 --------- .../__tests__/update-flow-action.test.ts | 91 ----- .../__tests__/update-sequence.action.test.ts | 315 ---------------- .../workspace-token-scope-enforcement.test.ts | 19 + .../src/features/ai-agents/api/public.ts | 97 ++++- .../ai-triggers/actions/create.action.ts | 10 +- .../ai-triggers/actions/delete.action.ts | 12 +- .../ai-triggers/actions/duplicate.action.ts | 25 +- .../ai-triggers/actions/list.action.ts | 43 --- .../ai-triggers/actions/update.action.ts | 29 +- .../src/features/ai-triggers/api/public.ts | 130 +++++++ .../src/features/ai-triggers/queries/index.ts | 14 + .../features/ai-triggers/schema/resource.ts | 12 + .../src/features/ai-triggers/table.tsx | 2 +- .../features/automated-response/api/public.ts | 136 ++++++- .../actions/create-bot-field.action.ts | 8 +- .../actions/update-bot-field.action.ts | 7 +- .../actions/update-broadcast.action.ts | 36 +- .../update-draft-flow-version-action.ts | 39 +- .../flows/actions/update-flow-action.ts | 49 +-- apps/builder/src/features/flows/api/public.ts | 235 +++++++++++- .../src/features/flows/queries/index.ts | 45 +-- .../reflinks/actions/create-reflink.action.ts | 17 +- .../reflinks/actions/update-reflink.action.ts | 67 +--- .../src/features/reflinks/api/public.ts | 94 ++++- .../src/features/reflinks/queries/index.ts | 38 +- .../actions/create-saved-reply.action.ts | 20 +- .../actions/edit-saved-reply.action.ts | 24 +- .../actions/update-sequence.action.ts | 106 ++---- .../src/features/triggers/api/public.ts | 166 ++++++++- apps/builder/src/routers/public.ts | 2 + docs/developer/workspace-api-tokens.md | 63 ++++ .../__tests__/ai-trigger-service.test.ts | 242 ++++++++++++ .../broadcast-service-update.test.ts | 118 ++++++ .../business/__tests__/flow-filters.test.ts | 41 +-- .../__tests__/flow-import-flow-export.test.ts | 5 + .../business/__tests__/flow.service.test.ts | 80 ++++ .../__tests__/reflink-service.test.ts | 201 ++++++++++ .../__tests__/sequence-service.test.ts | 102 ++++++ packages/business/src/ai-trigger/index.ts | 1 + packages/business/src/ai-trigger/service.ts | 131 +++++++ packages/business/src/bot-field/service.ts | 48 ++- packages/business/src/broadcast/service.ts | 22 ++ packages/business/src/flow-version/service.ts | 33 ++ .../business/src/flow/filters.ts | 10 +- packages/business/src/flow/index.ts | 1 + packages/business/src/flow/service.ts | 127 ++++++- packages/business/src/index.ts | 1 + packages/business/src/reflink/service.ts | 96 ++++- packages/business/src/saved-reply/service.ts | 41 +++ packages/business/src/sequence/service.ts | 58 +++ .../__tests__/ai-trigger-repository.test.ts | 109 ++++++ .../__tests__/reflink-repository.test.ts | 122 +++++++ .../src/repositories/ai-trigger/index.ts | 1 + .../src/repositories/ai-trigger/repository.ts | 54 +++ packages/database/src/repositories/index.ts | 2 + .../src/repositories/reflink/index.ts | 1 + .../src/repositories/reflink/repository.ts | 62 ++++ 65 files changed, 4292 insertions(+), 1135 deletions(-) create mode 100644 apps/builder/__tests__/ai-agents-public-api.test.ts create mode 100644 apps/builder/__tests__/ai-triggers-public-api.test.ts create mode 100644 apps/builder/__tests__/flows-public-api.test.ts create mode 100644 apps/builder/__tests__/keywords-public-api.test.ts create mode 100644 apps/builder/__tests__/reflinks-public-api.test.ts create mode 100644 apps/builder/__tests__/triggers-public-api.test.ts delete mode 100644 apps/builder/__tests__/update-broadcast.action.test.ts delete mode 100644 apps/builder/__tests__/update-flow-action.test.ts delete mode 100644 apps/builder/__tests__/update-sequence.action.test.ts delete mode 100644 apps/builder/src/features/ai-triggers/actions/list.action.ts create mode 100644 apps/builder/src/features/ai-triggers/api/public.ts create mode 100644 apps/builder/src/features/ai-triggers/queries/index.ts create mode 100644 apps/builder/src/features/ai-triggers/schema/resource.ts create mode 100644 packages/business/__tests__/ai-trigger-service.test.ts create mode 100644 packages/business/__tests__/broadcast-service-update.test.ts rename apps/builder/src/features/flows/actions/__tests__/filter-flow-action.test.ts => packages/business/__tests__/flow-filters.test.ts (81%) create mode 100644 packages/business/__tests__/reflink-service.test.ts create mode 100644 packages/business/src/ai-trigger/index.ts create mode 100644 packages/business/src/ai-trigger/service.ts rename apps/builder/src/features/flows/actions/filter-flow-action.ts => packages/business/src/flow/filters.ts (93%) create mode 100644 packages/database/__tests__/ai-trigger-repository.test.ts create mode 100644 packages/database/__tests__/reflink-repository.test.ts create mode 100644 packages/database/src/repositories/ai-trigger/index.ts create mode 100644 packages/database/src/repositories/ai-trigger/repository.ts create mode 100644 packages/database/src/repositories/reflink/index.ts create mode 100644 packages/database/src/repositories/reflink/repository.ts diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index c242e8b418..f4a6240859 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -2,11 +2,61 @@ exports[`public API spec — operation naming guard > operation list (operationId, method, path) matches the committed snapshot 1`] = ` [ + { + "method": "POST", + "operationId": "aiAgents.create", + "path": "/v1/ai-agents", + }, + { + "method": "DELETE", + "operationId": "aiAgents.delete", + "path": "/v1/ai-agents/{id}", + }, + { + "method": "GET", + "operationId": "aiAgents.get", + "path": "/v1/ai-agents/{id}", + }, { "method": "GET", "operationId": "aiAgents.list", "path": "/v1/ai-agents", }, + { + "method": "PUT", + "operationId": "aiAgents.update", + "path": "/v1/ai-agents/{id}", + }, + { + "method": "POST", + "operationId": "aiTriggers.create", + "path": "/v1/ai-triggers", + }, + { + "method": "DELETE", + "operationId": "aiTriggers.delete", + "path": "/v1/ai-triggers/{id}", + }, + { + "method": "POST", + "operationId": "aiTriggers.duplicate", + "path": "/v1/ai-triggers/{id}/duplicate", + }, + { + "method": "GET", + "operationId": "aiTriggers.get", + "path": "/v1/ai-triggers/{id}", + }, + { + "method": "GET", + "operationId": "aiTriggers.list", + "path": "/v1/ai-triggers", + }, + { + "method": "PUT", + "operationId": "aiTriggers.update", + "path": "/v1/ai-triggers/{id}", + }, { "method": "PUT", "operationId": "botFields.bulkUpdate", @@ -412,11 +462,56 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "externalWebhooks.list", "path": "/v1/external-webhooks", }, + { + "method": "POST", + "operationId": "flows.create", + "path": "/v1/flows", + }, + { + "method": "DELETE", + "operationId": "flows.delete", + "path": "/v1/flows/{id}", + }, + { + "method": "POST", + "operationId": "flows.duplicate", + "path": "/v1/flows/{id}/duplicate", + }, + { + "method": "GET", + "operationId": "flows.get", + "path": "/v1/flows/{id}", + }, + { + "method": "POST", + "operationId": "flows.import", + "path": "/v1/flows/import", + }, { "method": "GET", "operationId": "flows.list", "path": "/v1/flows", }, + { + "method": "POST", + "operationId": "flows.publish", + "path": "/v1/flows/{id}/publish", + }, + { + "method": "PATCH", + "operationId": "flows.update", + "path": "/v1/flows/{id}", + }, + { + "method": "PUT", + "operationId": "flows.updateDraft", + "path": "/v1/flows/{id}/draft", + }, + { + "method": "GET", + "operationId": "flows.versions", + "path": "/v1/flows/{id}/versions", + }, { "method": "POST", "operationId": "folders.create", @@ -482,11 +577,36 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "integrations.tokenErrors", "path": "/v1/integrations/status/token-errors", }, + { + "method": "POST", + "operationId": "keywords.create", + "path": "/v1/keywords", + }, + { + "method": "DELETE", + "operationId": "keywords.delete", + "path": "/v1/keywords/{id}", + }, + { + "method": "GET", + "operationId": "keywords.get", + "path": "/v1/keywords/{id}", + }, { "method": "GET", "operationId": "keywords.list", "path": "/v1/keywords", }, + { + "method": "PUT", + "operationId": "keywords.update", + "path": "/v1/keywords/{id}", + }, + { + "method": "PATCH", + "operationId": "keywords.updateStatus", + "path": "/v1/keywords/{id}/status", + }, { "method": "POST", "operationId": "productCategories.create", @@ -532,11 +652,31 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "products.update", "path": "/v1/products/{id}", }, + { + "method": "POST", + "operationId": "reflinks.create", + "path": "/v1/ref-links", + }, + { + "method": "DELETE", + "operationId": "reflinks.delete", + "path": "/v1/ref-links/{id}", + }, { "method": "GET", "operationId": "reflinks.get", "path": "/v1/ref-links/{id}", }, + { + "method": "GET", + "operationId": "reflinks.list", + "path": "/v1/ref-links", + }, + { + "method": "PUT", + "operationId": "reflinks.update", + "path": "/v1/ref-links/{id}", + }, { "method": "GET", "operationId": "savedReplies.list", @@ -582,11 +722,36 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "templateMessages.list", "path": "/v1/template-messages", }, + { + "method": "POST", + "operationId": "triggers.create", + "path": "/v1/triggers", + }, + { + "method": "DELETE", + "operationId": "triggers.delete", + "path": "/v1/triggers/{id}", + }, + { + "method": "GET", + "operationId": "triggers.get", + "path": "/v1/triggers/{id}", + }, { "method": "GET", "operationId": "triggers.list", "path": "/v1/triggers", }, + { + "method": "PUT", + "operationId": "triggers.update", + "path": "/v1/triggers/{id}", + }, + { + "method": "PATCH", + "operationId": "triggers.updateSettings", + "path": "/v1/triggers/{id}/settings", + }, { "method": "POST", "operationId": "webhooks.create", diff --git a/apps/builder/__tests__/ai-agents-public-api.test.ts b/apps/builder/__tests__/ai-agents-public-api.test.ts new file mode 100644 index 0000000000..fee38b36fb --- /dev/null +++ b/apps/builder/__tests__/ai-agents-public-api.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const aiAgentService = { + listAIAgents: vi.fn(), + findBy: vi.fn(), + create: vi.fn(), + updateAIAgent: vi.fn(), + delete: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ aiAgentService })) + +vi.mock("@chatbotx.io/business/errors", () => ({ + notFoundException: (message: string) => new Error(message), +})) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + aiAgentModel: {}, + } +}) + +await import("@/features/ai-agents/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the ai-agents public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/ai-agents", () => { + const procedure = findProcedure("GET", "/v1/ai-agents") + + test("delegates to aiAgentService.listAIAgents", async () => { + aiAgentService.listAIAgents.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(aiAgentService.listAIAgents).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + }) +}) + +describe("GET /v1/ai-agents/{id}", () => { + const procedure = findProcedure("GET", "/v1/ai-agents/{id}") + + test("delegates to aiAgentService.findBy", async () => { + aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "agent-1" }, + }) + + expect(aiAgentService.findBy).toHaveBeenCalledWith({ + where: { id: "agent-1", workspaceId: "workspace-1" }, + }) + }) + + test("throws not found when the agent does not exist", async () => { + aiAgentService.findBy.mockResolvedValueOnce(null) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing" }, + }), + ).rejects.toThrow("AI agent not found") + }) +}) + +describe("POST /v1/ai-agents", () => { + const procedure = findProcedure("POST", "/v1/ai-agents") + + test("delegates to aiAgentService.create then re-fetches via findBy", async () => { + aiAgentService.create.mockResolvedValueOnce(undefined) + aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "Support agent" }, + }) + + expect(aiAgentService.create).toHaveBeenCalledWith("workspace-1", { + name: "Support agent", + }) + expect(aiAgentService.findBy).toHaveBeenCalledWith({ + where: { workspaceId: "workspace-1", name: "Support agent" }, + }) + }) +}) + +describe("PUT /v1/ai-agents/{id}", () => { + const procedure = findProcedure("PUT", "/v1/ai-agents/{id}") + + test("delegates to aiAgentService.updateAIAgent then re-fetches via findBy", async () => { + aiAgentService.updateAIAgent.mockResolvedValueOnce(undefined) + aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "agent-1", name: "Renamed" }, + }) + + expect(aiAgentService.updateAIAgent).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "agent-1" }, + { name: "Renamed" }, + ) + }) +}) + +describe("DELETE /v1/ai-agents/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/ai-agents/{id}") + + test("delegates to aiAgentService.delete", async () => { + aiAgentService.delete.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "agent-1" }, + }) + + expect(aiAgentService.delete).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["agent-1"], + }) + }) +}) diff --git a/apps/builder/__tests__/ai-triggers-public-api.test.ts b/apps/builder/__tests__/ai-triggers-public-api.test.ts new file mode 100644 index 0000000000..654b7fdea7 --- /dev/null +++ b/apps/builder/__tests__/ai-triggers-public-api.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const aiTriggerService = { + list: vi.fn(), + findOrFail: vi.fn(), + create: vi.fn(), + update: vi.fn(), + duplicate: vi.fn(), + deleteMany: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ aiTriggerService })) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + aiTriggerModel: {}, + } +}) + +await import("@/features/ai-triggers/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the ai-triggers public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/ai-triggers", () => { + const procedure = findProcedure("GET", "/v1/ai-triggers") + + test("delegates to aiTriggerService.list", async () => { + aiTriggerService.list.mockResolvedValueOnce({ data: [], pageCount: 1 }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(aiTriggerService.list).toHaveBeenCalledWith({ + page: 1, + perPage: 50, + workspaceId: "workspace-1", + }) + }) +}) + +describe("GET /v1/ai-triggers/{id}", () => { + const procedure = findProcedure("GET", "/v1/ai-triggers/{id}") + + test("delegates to aiTriggerService.findOrFail", async () => { + aiTriggerService.findOrFail.mockResolvedValueOnce({ id: "ai-trigger-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "ai-trigger-1" }, + }) + + expect(aiTriggerService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "ai-trigger-1", + }) + }) +}) + +describe("POST /v1/ai-triggers", () => { + const procedure = findProcedure("POST", "/v1/ai-triggers") + + test("delegates to aiTriggerService.create", async () => { + aiTriggerService.create.mockResolvedValueOnce({ id: "ai-trigger-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "New AI trigger" }, + }) + + expect(aiTriggerService.create).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + data: { name: "New AI trigger" }, + }) + }) +}) + +describe("PUT /v1/ai-triggers/{id}", () => { + const procedure = findProcedure("PUT", "/v1/ai-triggers/{id}") + + test("delegates to aiTriggerService.update", async () => { + aiTriggerService.update.mockResolvedValueOnce({ id: "ai-trigger-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "ai-trigger-1", name: "Renamed" }, + }) + + expect(aiTriggerService.update).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "ai-trigger-1" }, + { name: "Renamed" }, + ) + }) +}) + +describe("POST /v1/ai-triggers/{id}/duplicate", () => { + const procedure = findProcedure("POST", "/v1/ai-triggers/{id}/duplicate") + + test("delegates to aiTriggerService.duplicate", async () => { + aiTriggerService.duplicate.mockResolvedValueOnce({ id: "ai-trigger-2" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "ai-trigger-1" }, + }) + + expect(aiTriggerService.duplicate).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "ai-trigger-1", + }) + }) +}) + +describe("DELETE /v1/ai-triggers/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/ai-triggers/{id}") + + test("delegates to aiTriggerService.deleteMany", async () => { + aiTriggerService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "ai-trigger-1" }, + }) + + expect(aiTriggerService.deleteMany).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["ai-trigger-1"], + }) + }) +}) diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts new file mode 100644 index 0000000000..19f9d01121 --- /dev/null +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -0,0 +1,343 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const flowService = { + list: vi.fn(), + findById: vi.fn(), + createDraft: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn(), + duplicate: vi.fn(), +} +const flowVersionService = { + publish: vi.fn(), + updateDraft: vi.fn(), + list: vi.fn(), +} +const importService = { + startFlowImport: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ + flowService, + flowVersionService, + importService, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + validationException: (field: string, message: string) => + new Error(`${field}: ${message}`), +})) + +vi.mock("@chatbotx.io/worker-config", () => ({ + DefaultJobAction: { runImport: "runImport" }, + defaultQueue: { add: vi.fn() }, +})) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + flowModel: {}, + flowVersionModel: {}, + } +}) + +await import("@/features/flows/api/public") +const { defaultQueue } = await import("@chatbotx.io/worker-config") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the flows public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/flows", () => { + const procedure = findProcedure("GET", "/v1/flows") + + test("delegates to flowService.list", async () => { + flowService.list.mockResolvedValueOnce({ data: [], pageCount: 1 }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, active: true }, + }) + + expect(flowService.list).toHaveBeenCalledWith({ + page: 1, + perPage: 50, + active: true, + workspaceId: "workspace-1", + }) + }) +}) + +describe("GET /v1/flows/{id}", () => { + const procedure = findProcedure("GET", "/v1/flows/{id}") + + test("delegates to flowService.findById", async () => { + flowService.findById.mockResolvedValueOnce({ id: "flow-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1" }, + }) + + expect(flowService.findById).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "flow-1", + }) + }) +}) + +describe("POST /v1/flows", () => { + const procedure = findProcedure("POST", "/v1/flows") + + test("route metadata", () => { + expect(procedure.route).toEqual( + expect.objectContaining({ + method: "POST", + path: "/v1/flows", + successStatus: 201, + }), + ) + }) + + test("delegates to flowService.createDraft", async () => { + flowService.createDraft.mockResolvedValueOnce({ id: "flow-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "New flow" }, + }) + + expect(flowService.createDraft).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + data: { name: "New flow" }, + }) + }) +}) + +describe("PATCH /v1/flows/{id}", () => { + const procedure = findProcedure("PATCH", "/v1/flows/{id}") + + test("delegates to flowService.update", async () => { + flowService.update.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1", name: "Renamed" }, + }) + + expect(flowService.update).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "flow-1" }, + { name: "Renamed" }, + ) + }) +}) + +describe("DELETE /v1/flows/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/flows/{id}") + + test("delegates to flowService.deleteMany", async () => { + flowService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1" }, + }) + + expect(flowService.deleteMany).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["flow-1"], + }) + }) +}) + +describe("POST /v1/flows/{id}/duplicate", () => { + const procedure = findProcedure("POST", "/v1/flows/{id}/duplicate") + + test("delegates to flowService.duplicate", async () => { + flowService.duplicate.mockResolvedValueOnce("flow-2") + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1" }, + }), + ).resolves.toEqual({ id: "flow-2" }) + + expect(flowService.duplicate).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "flow-1", + }) + }) +}) + +describe("POST /v1/flows/{id}/publish", () => { + const procedure = findProcedure("POST", "/v1/flows/{id}/publish") + + test("delegates to flowVersionService.publish", async () => { + flowVersionService.publish.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1", nodes: [], edges: [] }, + }) + + expect(flowVersionService.publish).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + flowId: "flow-1", + nodes: [], + edges: [], + }) + }) +}) + +describe("PUT /v1/flows/{id}/draft", () => { + const procedure = findProcedure("PUT", "/v1/flows/{id}/draft") + + test("delegates to flowVersionService.updateDraft", async () => { + flowVersionService.updateDraft.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1", nodes: [], edges: [] }, + }) + + expect(flowVersionService.updateDraft).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "flow-1", + nodes: [], + edges: [], + }) + }) +}) + +describe("GET /v1/flows/{id}/versions", () => { + const procedure = findProcedure("GET", "/v1/flows/{id}/versions") + + test("delegates to flowVersionService.list", async () => { + flowVersionService.list.mockResolvedValueOnce([{ id: "version-1" }]) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "flow-1" }, + }), + ).resolves.toEqual({ data: [{ id: "version-1" }] }) + + expect(flowVersionService.list).toHaveBeenCalledWith({ + flowId: "flow-1", + workspaceId: "workspace-1", + }) + }) +}) + +describe("POST /v1/flows/import", () => { + const procedure = findProcedure("POST", "/v1/flows/import") + + test("delegates to importService.startFlowImport and queues the import job", async () => { + importService.startFlowImport.mockResolvedValueOnce({ + ok: true, + importId: "import-1", + }) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1", ownerId: "user-1" } }, + input: { fileId: "file-1", folderId: null }, + }), + ).resolves.toEqual({ importId: "import-1" }) + + expect(importService.startFlowImport).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + userId: "user-1", + fileId: "file-1", + folderId: null, + }) + expect(defaultQueue.add).toHaveBeenCalledWith("runImport", { + type: "runImport", + data: { importId: "import-1" }, + }) + }) + + test("throws a validation exception when the file is not found", async () => { + importService.startFlowImport.mockResolvedValueOnce({ + ok: false, + reason: "fileNotFound", + }) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1", ownerId: "user-1" } }, + input: { fileId: "file-1", folderId: null }, + }), + ).rejects.toThrow("File not found") + + expect(defaultQueue.add).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/keywords-public-api.test.ts b/apps/builder/__tests__/keywords-public-api.test.ts new file mode 100644 index 0000000000..39638acdad --- /dev/null +++ b/apps/builder/__tests__/keywords-public-api.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const automatedResponseService = { + list: vi.fn(), + findOrFail: vi.fn(), + create: vi.fn(), + update: vi.fn(), + setStatus: vi.fn(), + deleteMany: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ automatedResponseService })) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + automatedResponseModel: {}, + } +}) + +await import("@/features/automated-response/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the keywords public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/keywords", () => { + const procedure = findProcedure("GET", "/v1/keywords") + + test("keeps the type filter in the where-clause instead of dropping it", async () => { + automatedResponseService.list.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, type: "outbound" }, + }) + + expect(automatedResponseService.list).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "workspace-1", + type: "outbound", + page: 1, + perPage: 50, + }), + ) + }) + + test("defaults type to inbound when omitted", async () => { + automatedResponseService.list.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, type: "inbound" }, + }) + + expect(automatedResponseService.list).toHaveBeenCalledWith( + expect.objectContaining({ type: "inbound" }), + ) + }) +}) + +describe("GET /v1/keywords/{id}", () => { + const procedure = findProcedure("GET", "/v1/keywords/{id}") + + test("delegates to automatedResponseService.findOrFail", async () => { + automatedResponseService.findOrFail.mockResolvedValueOnce({ + id: "keyword-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-1" }, + }) + + expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "keyword-1", + }) + }) +}) + +describe("POST /v1/keywords", () => { + const procedure = findProcedure("POST", "/v1/keywords") + + test("delegates to automatedResponseService.create", async () => { + automatedResponseService.create.mockResolvedValueOnce({ + id: "keyword-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { type: "inbound", keywords: ["hi"] }, + }) + + expect(automatedResponseService.create).toHaveBeenCalledWith( + "workspace-1", + { type: "inbound", keywords: ["hi"] }, + ) + }) +}) + +describe("PUT /v1/keywords/{id}", () => { + const procedure = findProcedure("PUT", "/v1/keywords/{id}") + + test("verifies existence then delegates to automatedResponseService.update", async () => { + automatedResponseService.findOrFail.mockResolvedValueOnce({ + id: "keyword-1", + }) + automatedResponseService.update.mockResolvedValueOnce({ + id: "keyword-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-1", keywords: ["hello"] }, + }) + + expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "keyword-1", + }) + expect(automatedResponseService.update).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "keyword-1" }, + { keywords: [{ value: "hello" }] }, + ) + }) +}) + +describe("PATCH /v1/keywords/{id}/status", () => { + const procedure = findProcedure("PATCH", "/v1/keywords/{id}/status") + + test("verifies existence then delegates to automatedResponseService.setStatus", async () => { + automatedResponseService.findOrFail.mockResolvedValueOnce({ + id: "keyword-1", + }) + automatedResponseService.setStatus.mockResolvedValueOnce({ + id: "keyword-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-1", status: false }, + }) + + expect(automatedResponseService.setStatus).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "keyword-1" }, + false, + ) + }) +}) + +describe("DELETE /v1/keywords/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/keywords/{id}") + + test("delegates to automatedResponseService.deleteMany", async () => { + automatedResponseService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-1" }, + }) + + expect(automatedResponseService.deleteMany).toHaveBeenCalledWith( + "workspace-1", + ["keyword-1"], + ) + }) +}) diff --git a/apps/builder/__tests__/reflinks-public-api.test.ts b/apps/builder/__tests__/reflinks-public-api.test.ts new file mode 100644 index 0000000000..8f8579dbe5 --- /dev/null +++ b/apps/builder/__tests__/reflinks-public-api.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const reflinkService = { + list: vi.fn(), + findOrFail: vi.fn(), + create: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ reflinkService })) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + optional: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + reflinkModel: {}, + } +}) + +await import("@/features/reflinks/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the reflinks public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/ref-links", () => { + const procedure = findProcedure("GET", "/v1/ref-links") + + test("delegates to reflinkService.list", async () => { + reflinkService.list.mockResolvedValueOnce({ data: [], pageCount: 1 }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(reflinkService.list).toHaveBeenCalledWith({ + page: 1, + perPage: 50, + workspaceId: "workspace-1", + }) + }) +}) + +describe("GET /v1/ref-links/{id}", () => { + const procedure = findProcedure("GET", "/v1/ref-links/{id}") + + test("delegates to reflinkService.findOrFail", async () => { + reflinkService.findOrFail.mockResolvedValueOnce({ id: "reflink-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "reflink-1" }, + }) + + expect(reflinkService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "reflink-1", + }) + }) +}) + +describe("POST /v1/ref-links", () => { + const procedure = findProcedure("POST", "/v1/ref-links") + + test("delegates to reflinkService.create", async () => { + reflinkService.create.mockResolvedValueOnce({ id: "reflink-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { flowId: "flow-1", type: "refLink" }, + }) + + expect(reflinkService.create).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + data: { flowId: "flow-1", type: "refLink" }, + }) + }) +}) + +describe("PUT /v1/ref-links/{id}", () => { + const procedure = findProcedure("PUT", "/v1/ref-links/{id}") + + test("delegates to reflinkService.update", async () => { + reflinkService.update.mockResolvedValueOnce({ id: "reflink-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "reflink-1", flowId: "flow-2" }, + }) + + expect(reflinkService.update).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "reflink-1" }, + { flowId: "flow-2" }, + ) + }) +}) + +describe("DELETE /v1/ref-links/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/ref-links/{id}") + + test("delegates to reflinkService.deleteMany", async () => { + reflinkService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "reflink-1" }, + }) + + expect(reflinkService.deleteMany).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["reflink-1"], + }) + }) +}) diff --git a/apps/builder/__tests__/triggers-public-api.test.ts b/apps/builder/__tests__/triggers-public-api.test.ts new file mode 100644 index 0000000000..56df299427 --- /dev/null +++ b/apps/builder/__tests__/triggers-public-api.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const triggerService = { + listByWorkspaceId: vi.fn(), + create: vi.fn(), + updateWithConditions: vi.fn(), + updateSettings: vi.fn(), + deleteMany: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ triggerService })) + +vi.mock("@chatbotx.io/business/errors", () => ({ + notFoundException: (message: string) => new Error(message), +})) + +const triggerRepository = { + findWithConditions: vi.fn(), +} +vi.mock("@chatbotx.io/database/repositories", () => ({ triggerRepository })) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + triggerModel: {}, + } +}) + +await import("@/features/triggers/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the triggers public router under the automation scope", () => { + expect(scopeArgAtImport).toBe("automation") +}) + +describe("GET /v1/triggers", () => { + const procedure = findProcedure("GET", "/v1/triggers") + + test("returns real conditions and actions, not hardcoded empty arrays", async () => { + triggerService.listByWorkspaceId.mockResolvedValueOnce([ + { id: "trigger-1" }, + ]) + triggerRepository.findWithConditions.mockResolvedValueOnce({ + id: "trigger-1", + conditions: [{ id: "c1", type: "newContact" }], + actions: [{ id: "a1", type: "sendFlow" }], + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(triggerService.listByWorkspaceId).toHaveBeenCalledWith("workspace-1") + expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ + id: "trigger-1", + workspaceId: "workspace-1", + }) + expect(result.data[0].conditions).toEqual([ + { id: "c1", type: "newContact" }, + ]) + expect(result.data[0].actions).toEqual([{ id: "a1", type: "sendFlow" }]) + }) +}) + +describe("GET /v1/triggers/{id}", () => { + const procedure = findProcedure("GET", "/v1/triggers/{id}") + + test("delegates to triggerRepository.findWithConditions", async () => { + triggerRepository.findWithConditions.mockResolvedValueOnce({ + id: "trigger-1", + conditions: [], + actions: [], + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "trigger-1" }, + }) + + expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ + id: "trigger-1", + workspaceId: "workspace-1", + }) + }) + + test("throws not found when the trigger does not exist", async () => { + triggerRepository.findWithConditions.mockResolvedValueOnce(null) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing" }, + }), + ).rejects.toThrow("Trigger not found") + }) +}) + +describe("POST /v1/triggers", () => { + const procedure = findProcedure("POST", "/v1/triggers") + + test("delegates to triggerService.create", async () => { + triggerService.create.mockResolvedValueOnce({ id: "trigger-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "New trigger" }, + }) + + expect(triggerService.create).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + data: { name: "New trigger" }, + folderType: "trigger", + }) + }) +}) + +describe("PUT /v1/triggers/{id}", () => { + const procedure = findProcedure("PUT", "/v1/triggers/{id}") + + test("delegates to triggerService.updateWithConditions", async () => { + triggerService.updateWithConditions.mockResolvedValueOnce({ + id: "trigger-1", + }) + triggerRepository.findWithConditions.mockResolvedValueOnce({ + id: "trigger-1", + conditions: [], + actions: [], + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + id: "trigger-1", + conditions: [{ type: "newContact" }], + actions: [{ type: "sendFlow" }], + }, + }) + + expect(triggerService.updateWithConditions).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "trigger-1", + actions: [{ type: "sendFlow" }], + conditions: [ + { + id: undefined, + type: "newContact", + sourceId: null, + operator: null, + value: null, + }, + ], + }) + }) + + test("throws not found when the trigger update fails to match", async () => { + triggerService.updateWithConditions.mockResolvedValueOnce(null) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing", conditions: [], actions: [] }, + }), + ).rejects.toThrow("Trigger not found") + }) +}) + +describe("PATCH /v1/triggers/{id}/settings", () => { + const procedure = findProcedure("PATCH", "/v1/triggers/{id}/settings") + + test("delegates to triggerService.updateSettings", async () => { + triggerService.updateSettings.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "trigger-1", active: false }, + }) + + expect(triggerService.updateSettings).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "trigger-1", + active: false, + }) + }) +}) + +describe("DELETE /v1/triggers/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/triggers/{id}") + + test("delegates to triggerService.deleteMany", async () => { + triggerService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "trigger-1" }, + }) + + expect(triggerService.deleteMany).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["trigger-1"], + }) + }) +}) diff --git a/apps/builder/__tests__/update-broadcast.action.test.ts b/apps/builder/__tests__/update-broadcast.action.test.ts deleted file mode 100644 index f73d66b3ae..0000000000 --- a/apps/builder/__tests__/update-broadcast.action.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, test, vi } from "vitest" - -const { - mockDbUpdate, - mockUpdateSet, - mockUpdateWhere, - mockFindOrFail, - mockEq, - mockRecordAuditLog, -} = vi.hoisted(() => { - const mockUpdateWhere = vi.fn().mockResolvedValue(undefined) - const mockUpdateSet = vi.fn() - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - const mockDbUpdate = vi.fn() - mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) - - return { - mockDbUpdate, - mockUpdateSet, - mockUpdateWhere, - mockFindOrFail: vi.fn(), - mockEq: vi.fn((col: unknown, val: unknown) => ({ __eq: [col, val] })), - mockRecordAuditLog: vi.fn(), - } -}) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: (...args: unknown[]) => mockRecordAuditLog(...args) }, -})) - -vi.mock("@/lib/safe-action", () => { - const chain: Record = {} - chain.bindArgsSchemas = () => chain - chain.inputSchema = () => chain - chain.action = (fn: unknown) => fn - return { workspaceActionClient: chain } -}) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - update: mockDbUpdate, - }, - eq: mockEq, - findOrFail: mockFindOrFail, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - broadcastModel: { id: "broadcastModelId" }, -})) - -const { updateBroadcast } = await import( - "../src/features/broadcasts/actions/update-broadcast.action" -) - -const WORKSPACE_ID = "ws-1" -const BROADCAST_ID = "bc-1" - -describe("updateBroadcast", () => { - beforeEach(() => { - vi.clearAllMocks() - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateWhere.mockResolvedValue(undefined) - }) - - test("propagates error when findOrFail throws (broadcast not found)", async () => { - const notFoundError = new Error("Not found") - mockFindOrFail.mockRejectedValue(notFoundError) - - await expect( - updateBroadcast( - { workspaceId: WORKSPACE_ID, id: BROADCAST_ID }, - { name: "New Name" }, - ), - ).rejects.toThrow("Not found") - }) - - test("calls db.update with parsedInput after finding broadcast", async () => { - const mockBroadcast = { - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - name: "Broadcast", - } - mockFindOrFail.mockResolvedValue(mockBroadcast) - - await updateBroadcast( - { workspaceId: WORKSPACE_ID, id: BROADCAST_ID }, - { name: "Updated Name" }, - ) - - expect(mockFindOrFail).toHaveBeenCalledOnce() - expect(mockFindOrFail).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - }), - }), - ) - expect(mockDbUpdate).toHaveBeenCalledOnce() - expect(mockUpdateSet).toHaveBeenCalledWith({ name: "Updated Name" }) - expect(mockRecordAuditLog).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - action: "update", - detail: `updated a broadcast (#${BROADCAST_ID})`, - }) - }) - - test("scopes findOrFail by workspaceId to prevent cross-workspace access", async () => { - const mockBroadcast = { - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - name: "Broadcast", - } - mockFindOrFail.mockResolvedValue(mockBroadcast) - - await updateBroadcast( - { workspaceId: "other-ws", id: BROADCAST_ID }, - { name: "Name" }, - ) - - const findOrFailArgs = mockFindOrFail.mock.calls[0]?.[0] as { - where: { workspaceId: string } - } - expect(findOrFailArgs.where.workspaceId).toBe("other-ws") - }) - - test("uses eq(broadcastModel.id, broadcast.id) in the where clause", async () => { - const mockBroadcast = { - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - name: "Broadcast", - } - mockFindOrFail.mockResolvedValue(mockBroadcast) - - await updateBroadcast( - { workspaceId: WORKSPACE_ID, id: BROADCAST_ID }, - { name: "Name" }, - ) - - expect(mockEq).toHaveBeenCalledWith(expect.anything(), mockBroadcast.id) - expect(mockUpdateWhere).toHaveBeenCalledWith( - expect.objectContaining({ __eq: expect.any(Array) }), - ) - }) - - test("returns undefined on success", async () => { - const mockBroadcast = { - id: BROADCAST_ID, - workspaceId: WORKSPACE_ID, - name: "Broadcast", - } - mockFindOrFail.mockResolvedValue(mockBroadcast) - - const result = await updateBroadcast( - { workspaceId: WORKSPACE_ID, id: BROADCAST_ID }, - { name: "Final Name" }, - ) - - expect(result).toBeUndefined() - }) -}) diff --git a/apps/builder/__tests__/update-flow-action.test.ts b/apps/builder/__tests__/update-flow-action.test.ts deleted file mode 100644 index 3ee5ee956f..0000000000 --- a/apps/builder/__tests__/update-flow-action.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, test, vi } from "vitest" - -const mocks = vi.hoisted(() => { - const updateReturning = vi.fn() - const updateWhere = vi.fn(() => ({ returning: updateReturning })) - const updateSet = vi.fn(() => ({ where: updateWhere })) - const dbUpdate = vi.fn(() => ({ set: updateSet })) - - return { - auditRecord: vi.fn(), - dbUpdate, - findOrFail: vi.fn(), - updateReturning, - updateSet, - updateWhere, - } -}) - -vi.mock("@/lib/safe-action", () => { - const chain: Record = {} - chain.bindArgsSchemas = () => chain - chain.inputSchema = () => chain - chain.action = (fn: unknown) => fn - return { workspaceActionClient: chain } -}) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mocks.auditRecord }, -})) - -vi.mock("@chatbotx.io/database/client", () => ({ - db: { update: mocks.dbUpdate }, - eq: (...args: unknown[]) => ({ eq: args }), - findOrFail: mocks.findOrFail, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - flowModel: { id: "flow.id" }, -})) - -const { updateFlowAction } = await import( - "../src/features/flows/actions/update-flow-action" -) - -type ActionHandler = (args: { - bindArgsParsedInputs: [string, string] - parsedInput: { name?: string; active?: boolean; enableInInbox?: boolean } -}) => Promise - -const callAction = updateFlowAction as unknown as ActionHandler - -describe("updateFlowAction", () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.findOrFail.mockResolvedValue({ - id: "flow-1", - workspaceId: "workspace-1", - name: "Welcome", - active: true, - enableInInbox: true, - }) - mocks.updateReturning.mockResolvedValue([{ id: "flow-1" }]) - }) - - test("skips DB update and audit when submitted fields are unchanged", async () => { - await callAction({ - bindArgsParsedInputs: ["workspace-1", "flow-1"], - parsedInput: { name: "Welcome", active: true }, - }) - - expect(mocks.dbUpdate).not.toHaveBeenCalled() - expect(mocks.auditRecord).not.toHaveBeenCalled() - }) - - test("updates and audits when a field changed", async () => { - await callAction({ - bindArgsParsedInputs: ["workspace-1", "flow-1"], - parsedInput: { name: "Onboarding" }, - }) - - expect(mocks.updateSet).toHaveBeenCalledWith({ name: "Onboarding" }) - expect(mocks.updateReturning).toHaveBeenCalledWith({ id: "flow.id" }) - expect(mocks.auditRecord).toHaveBeenCalledWith({ - workspaceId: "workspace-1", - action: "update", - detail: "updated a flow (#flow-1)", - }) - }) -}) diff --git a/apps/builder/__tests__/update-sequence.action.test.ts b/apps/builder/__tests__/update-sequence.action.test.ts deleted file mode 100644 index fbbe83a2a7..0000000000 --- a/apps/builder/__tests__/update-sequence.action.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, test, vi } from "vitest" - -const { - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockFindOrFail, - mockIsDatabaseError, - mockReturnValidationErrors, - mockGetTranslations, - mockAuditRecord, -} = vi.hoisted(() => { - const mockUpdateReturning = vi.fn().mockResolvedValue([{ id: "seq-1" }]) - const mockUpdateWhere = vi.fn().mockReturnValue({ - returning: mockUpdateReturning, - }) - const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) - - return { - mockUpdateReturning, - mockUpdateWhere, - mockUpdateSet, - mockUpdate, - mockFindOrFail: vi.fn().mockResolvedValue({ id: "seq-1", name: "Seq" }), - mockIsDatabaseError: vi.fn().mockReturnValue(false), - mockReturnValidationErrors: vi - .fn() - .mockReturnValue({ __validationError: true }), - mockGetTranslations: vi.fn().mockResolvedValue((k: string) => k), - mockAuditRecord: vi.fn().mockResolvedValue(undefined), - } -}) - -vi.mock("@/lib/safe-action", () => { - const chain: Record = {} - chain.bindArgsSchemas = () => chain - chain.inputSchema = () => chain - chain.action = (fn: unknown) => fn - return { workspaceActionClient: chain } -}) - -vi.mock("@chatbotx.io/database/client", () => ({ - and: (...args: unknown[]) => ({ and: args }), - db: { update: mockUpdate }, - eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), - findOrFail: mockFindOrFail, - isDatabaseError: mockIsDatabaseError, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - sequenceModel: { id: "id", name: "name", workspaceId: "workspaceId" }, -})) - -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mockAuditRecord }, -})) - -vi.mock("next-intl/server", () => ({ - getTranslations: mockGetTranslations, -})) - -vi.mock("next-safe-action", () => ({ - returnValidationErrors: mockReturnValidationErrors, -})) - -vi.mock("@/features/sequences/schema/action", () => ({ - updateSequenceSchema: {}, -})) - -const { updateSequenceAction, updateSequence } = await import( - "../src/features/sequences/actions/update-sequence.action" -) - -// With the safe-action chain mock, the exported action IS the raw handler. -type ActionHandler = (args: { - bindArgsParsedInputs: [string, string] - parsedInput: { name?: string; active?: boolean } -}) => Promise - -const callAction = updateSequenceAction as unknown as ActionHandler - -const WS = "ws-1" -const SEQ_ID = "seq-1" - -// Resets shared mock chain state between tests -function resetUpdateChain() { - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) - mockUpdateReturning.mockResolvedValue([{ id: "seq-1" }]) -} - -describe("updateSequenceAction", () => { - beforeEach(() => { - vi.clearAllMocks() - resetUpdateChain() - mockFindOrFail.mockResolvedValue({ - id: "seq-1", - name: "Seq", - active: false, - }) - mockIsDatabaseError.mockReturnValue(false) - mockGetTranslations.mockResolvedValue((k: string) => k) - mockReturnValidationErrors.mockReturnValue({ __validationError: true }) - }) - - describe("happy path", () => { - test("calls findOrFail then db.update on successful update", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated Name" }, - }) - - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenCalledTimes(1) - }) - - test("calls findOrFail with workspace-scoped where clause", async () => { - // Act - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated" }, - }) - - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { id: string; workspaceId: string } - message: string - } - expect(args.where.id).toBe(SEQ_ID) - expect(args.where.workspaceId).toBe(WS) - expect(args.message).toBe("Sequence not found") - }) - - test("passes parsedInput directly to db.update.set", async () => { - // Arrange - const parsedInput = { name: "New Name", active: true } - - // Act - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput, - }) - - // Assert - expect(mockUpdateSet).toHaveBeenCalledWith(parsedInput) - }) - - test("skips update and audit when active is unchanged", async () => { - mockFindOrFail.mockResolvedValue({ - id: "seq-1", - name: "Seq", - active: true, - }) - - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { active: true }, - }) - - expect(mockUpdate).not.toHaveBeenCalled() - expect(mockAuditRecord).not.toHaveBeenCalled() - }) - - test("records enabled detail only when active actually changes by itself", async () => { - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { active: true }, - }) - - expect(mockAuditRecord).toHaveBeenCalledWith({ - workspaceId: WS, - action: "update", - detail: "enabled a sequence (#seq-1)", - }) - }) - - test("records generic update detail when name and active change together", async () => { - await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "New Name", active: true }, - }) - - expect(mockAuditRecord).toHaveBeenCalledWith({ - workspaceId: WS, - action: "update", - detail: "updated a sequence (#seq-1)", - }) - }) - - test("returns undefined on success (no explicit return value)", async () => { - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated Seq" }, - }) - - // Assert - expect(result).toBeUndefined() - }) - }) - - describe("sequence not found", () => { - test("propagates findOrFail error and does not call db.update", async () => { - // Arrange - mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated" }, - }), - ).rejects.toThrow("Sequence not found") - expect(mockUpdate).not.toHaveBeenCalled() - }) - }) - - describe("unique violation (23505)", () => { - test("returns returnValidationErrors result on duplicate name", async () => { - // Arrange - const dbError = Object.assign(new Error("unique violation"), { - cause: { code: "23505" }, - }) - mockUpdateReturning.mockRejectedValue(dbError) - mockIsDatabaseError.mockReturnValue(true) - - // Act - const result = await callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Duplicate" }, - }) - - // Assert - expect(mockReturnValidationErrors).toHaveBeenCalledTimes(1) - expect(result).toEqual({ __validationError: true }) - }) - }) - - describe("other DB errors", () => { - test("throws 'Failed to update sequence' for non-23505 DB error", async () => { - // Arrange - const dbError = Object.assign(new Error("other db"), { - cause: { code: "XXXXX" }, - }) - mockUpdateReturning.mockRejectedValue(dbError) - mockIsDatabaseError.mockReturnValue(true) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated Seq" }, - }), - ).rejects.toThrow("Failed to update sequence") - expect(mockReturnValidationErrors).not.toHaveBeenCalled() - }) - - test("throws 'Failed to update sequence' for non-DB errors", async () => { - // Arrange - mockUpdateReturning.mockRejectedValue(new Error("network error")) - mockIsDatabaseError.mockReturnValue(false) - - // Act & Assert - await expect( - callAction({ - bindArgsParsedInputs: [WS, SEQ_ID], - parsedInput: { name: "Updated Seq" }, - }), - ).rejects.toThrow("Failed to update sequence") - }) - }) -}) - -describe("updateSequence (exported helper)", () => { - beforeEach(() => { - vi.clearAllMocks() - resetUpdateChain() - mockFindOrFail.mockResolvedValue({ - id: "seq-1", - name: "Seq", - active: false, - }) - mockIsDatabaseError.mockReturnValue(false) - mockGetTranslations.mockResolvedValue((k: string) => k) - mockReturnValidationErrors.mockReturnValue({ __validationError: true }) - }) - - test("is directly callable with ctx and parsedInput", async () => { - // Act - await updateSequence({ workspaceId: WS, id: SEQ_ID }, { name: "Direct" }) - - // Assert - expect(mockFindOrFail).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdateSet).toHaveBeenCalledWith({ name: "Direct" }) - }) - - test("scopes findOrFail to the provided workspaceId", async () => { - // Act - await updateSequence({ workspaceId: "other-ws", id: SEQ_ID }, {}) - - // Assert - const args = mockFindOrFail.mock.calls[0]?.[0] as { - where: { workspaceId: string } - } - expect(args.where.workspaceId).toBe("other-ws") - }) -}) diff --git a/apps/builder/__tests__/workspace-token-scope-enforcement.test.ts b/apps/builder/__tests__/workspace-token-scope-enforcement.test.ts index 385669dd73..2dea6b8d52 100644 --- a/apps/builder/__tests__/workspace-token-scope-enforcement.test.ts +++ b/apps/builder/__tests__/workspace-token-scope-enforcement.test.ts @@ -152,6 +152,25 @@ describe("workspace API token resource-scope enforcement", () => { }) }) + test("a token without the automation scope is denied a new automation write route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + const procedure = buildProcedure("automation", "POST") + + await expect(invoke(procedure)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'automation' scope", + }) + }) + + test("an automation-scoped token can hit an automation write route (e.g. POST /v1/flows, POST /v1/ai-triggers)", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["automation"])) + + const procedure = buildProcedure("automation", "POST") + + await expect(invoke(procedure)).resolves.toMatchObject({ ok: true }) + }) + test("scope enforcement runs after the read_only permission gate: a read_only token is still blocked from a mutation regardless of scope", async () => { findWorkspaceByTokenHash.mockResolvedValue( authResult(["contacts"], "read_only"), diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 213e30d1ad..1ab9c78475 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -1,8 +1,19 @@ import { aiAgentService } from "@chatbotx.io/business" -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { notFoundException } from "@chatbotx.io/business/errors" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" import { publicListRequest } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { createAIAgentRequest, updateAIAgentRequest } from "../schema/action" import { listAIAgentsResponse } from "../schema/query" +import { aiAgentResourceSchema } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") @@ -25,4 +36,88 @@ export const aiAgentsPublicRouter = { sort: [{ id: "createdAt", desc: true }], }), ), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ai-agents/{id}", + summary: "Get an AI agent by id", + tags: ["AI Agents"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(aiAgentResourceSchema) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const aiAgent = await aiAgentService.findBy({ + where: { id: input.id, workspaceId: context.workspace.id }, + }) + if (!aiAgent) { + throw notFoundException("AI agent not found") + } + return aiAgent + }), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ai-agents", + summary: "Create an AI agent", + successStatus: 201, + tags: ["AI Agents"], + }) + .input(createAIAgentRequest) + .output(aiAgentResourceSchema) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + await aiAgentService.create(context.workspace.id, input) + const created = await aiAgentService.findBy({ + where: { workspaceId: context.workspace.id, name: input.name }, + }) + if (!created) { + throw notFoundException("AI agent not found") + } + return created + }), + + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/ai-agents/{id}", + summary: "Update an AI agent", + tags: ["AI Agents"], + }) + .input(updateAIAgentRequest.and(z.object({ id: zodBigintAsString() }))) + .output(aiAgentResourceSchema) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...data } = input + await aiAgentService.updateAIAgent( + { workspaceId: context.workspace.id, id }, + data, + ) + const updated = await aiAgentService.findBy({ + where: { id, workspaceId: context.workspace.id }, + }) + if (!updated) { + throw notFoundException("AI agent not found") + } + return updated + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/ai-agents/{id}", + summary: "Delete an AI agent", + successStatus: 204, + tags: ["AI Agents"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await aiAgentService.delete({ + workspaceId: context.workspace.id, + ids: [input.id], + }) + }), } diff --git a/apps/builder/src/features/ai-triggers/actions/create.action.ts b/apps/builder/src/features/ai-triggers/actions/create.action.ts index 6e32a9bf10..2dccdf723a 100644 --- a/apps/builder/src/features/ai-triggers/actions/create.action.ts +++ b/apps/builder/src/features/ai-triggers/actions/create.action.ts @@ -1,8 +1,6 @@ "use server" -import { db } from "@chatbotx.io/database/client" -import { aiTriggerModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" +import { aiTriggerService } from "@chatbotx.io/business" import { createAITriggerRequest } from "@/features/ai-triggers/schema/action" import { workspaceIdrequestParams } from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" @@ -16,9 +14,5 @@ export const createAITriggerAction = workspaceActionClient parsedInput, } = props - await db.insert(aiTriggerModel).values({ - ...parsedInput, - workspaceId, - id: createId(), - }) + await aiTriggerService.create({ workspaceId, data: parsedInput }) }) diff --git a/apps/builder/src/features/ai-triggers/actions/delete.action.ts b/apps/builder/src/features/ai-triggers/actions/delete.action.ts index f62539ae6e..7d374d2de9 100644 --- a/apps/builder/src/features/ai-triggers/actions/delete.action.ts +++ b/apps/builder/src/features/ai-triggers/actions/delete.action.ts @@ -1,7 +1,6 @@ "use server" -import { and, db, eq, inArray } from "@chatbotx.io/database/client" -import { aiTriggerModel } from "@chatbotx.io/database/schema" +import { aiTriggerService } from "@chatbotx.io/business" import { bulkUpdateIdsRequest, workspaceIdrequestParams, @@ -17,12 +16,5 @@ export const deleteAITriggerAction = workspaceActionClient parsedInput: { ids }, } = props - await db - .delete(aiTriggerModel) - .where( - and( - eq(aiTriggerModel.workspaceId, workspaceId), - inArray(aiTriggerModel.id, ids), - ), - ) + await aiTriggerService.deleteMany({ workspaceId, ids }) }) diff --git a/apps/builder/src/features/ai-triggers/actions/duplicate.action.ts b/apps/builder/src/features/ai-triggers/actions/duplicate.action.ts index 82cf798485..235862e38b 100644 --- a/apps/builder/src/features/ai-triggers/actions/duplicate.action.ts +++ b/apps/builder/src/features/ai-triggers/actions/duplicate.action.ts @@ -1,7 +1,6 @@ "use server" -import { db, findOrFail } from "@chatbotx.io/database/client" -import { aiTriggerModel } from "@chatbotx.io/database/schema" +import { aiTriggerService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" @@ -12,25 +11,5 @@ export const duplicateAITriggerAction = workspaceActionClient bindArgsParsedInputs: [workspaceId, id], } = props - return await duplicateAITrigger({ workspaceId, id }) + return await aiTriggerService.duplicate({ workspaceId, id }) }) - -export const duplicateAITrigger = async (ctx: { - workspaceId: string - id: string -}) => { - const targetAITrigger = await findOrFail({ - table: aiTriggerModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "AITrigger not found", - }) - const { id: eid, name, createdAt, updatedAt, ...rest } = targetAITrigger - - await db.insert(aiTriggerModel).values({ - ...rest, - name: `${name} _copy`, - }) -} diff --git a/apps/builder/src/features/ai-triggers/actions/list.action.ts b/apps/builder/src/features/ai-triggers/actions/list.action.ts deleted file mode 100644 index 26574a12f1..0000000000 --- a/apps/builder/src/features/ai-triggers/actions/list.action.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { db, relationsFilterToSQL } from "@chatbotx.io/database/client" -import { aiTriggerModel } from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, - parseOrderByAsObject, -} from "@chatbotx.io/database/utils" -import type { - AITriggerCollection, - ListAITriggersRequest, -} from "@/features/ai-triggers/schema/query" -import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" - -export const listAITriggers = async ( - input: ListAITriggersRequest, -): Promise => { - await assertCurrentUserCanAccessChatbot(input.workspaceId) - - const where = { - workspaceId: input.workspaceId, - name: input.name - ? { - ilike: likeContains(input.name), - } - : undefined, - } - - const pagination = getPaginationWithDefaults(input) - const orderBy = parseOrderByAsObject(aiTriggerModel, input) - - const [data, total] = await Promise.all([ - db.query.aiTriggerModel.findMany({ - where, - orderBy, - ...pagination, - }), - db.$count(aiTriggerModel, relationsFilterToSQL(aiTriggerModel, where)), - ]) - - const pageCount = Math.ceil(total / pagination.limit) - - return { data, pageCount } -} diff --git a/apps/builder/src/features/ai-triggers/actions/update.action.ts b/apps/builder/src/features/ai-triggers/actions/update.action.ts index c9691211ab..39ecf98171 100644 --- a/apps/builder/src/features/ai-triggers/actions/update.action.ts +++ b/apps/builder/src/features/ai-triggers/actions/update.action.ts @@ -1,12 +1,8 @@ "use server" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { aiTriggerModel } from "@chatbotx.io/database/schema" +import { aiTriggerService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" -import { - type UpdateAITriggerRequest, - updateAITriggerRequest, -} from "@/features/ai-triggers/schema/action" +import { updateAITriggerRequest } from "@/features/ai-triggers/schema/action" import { workspaceActionClient } from "@/lib/safe-action" export const updateAITriggerAction = workspaceActionClient @@ -18,24 +14,5 @@ export const updateAITriggerAction = workspaceActionClient parsedInput, } = props - return await updateAITrigger({ workspaceId, id }, parsedInput) + return await aiTriggerService.update({ workspaceId, id }, parsedInput) }) - -export const updateAITrigger = async ( - ctx: { workspaceId: string; id: string }, - parsedInput: UpdateAITriggerRequest, -) => { - const aiTrigger = await findOrFail({ - table: aiTriggerModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "AITrigger not found", - }) - - await db - .update(aiTriggerModel) - .set(parsedInput) - .where(eq(aiTriggerModel.id, aiTrigger.id)) -} diff --git a/apps/builder/src/features/ai-triggers/api/public.ts b/apps/builder/src/features/ai-triggers/api/public.ts new file mode 100644 index 0000000000..7f4e8aa53c --- /dev/null +++ b/apps/builder/src/features/ai-triggers/api/public.ts @@ -0,0 +1,130 @@ +import { aiTriggerService } from "@chatbotx.io/business" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { publicListRequest, publicListResponse } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + createAITriggerRequest, + updateAITriggerRequest, +} from "../schema/action" +import { aiTriggerResource } from "../schema/resource" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") + +export const aiTriggersPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ai-triggers", + summary: "List AI triggers", + tags: ["AI Triggers"], + }) + .input(publicListRequest) + .output(publicListResponse(aiTriggerResource)) + .errors(possibleErrorsOnListingResource) + .handler( + async ({ context, input }) => + await aiTriggerService.list({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ai-triggers/{id}", + summary: "Get an AI trigger by id", + tags: ["AI Triggers"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(aiTriggerResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await aiTriggerService.findOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ai-triggers", + summary: "Create an AI trigger", + successStatus: 201, + tags: ["AI Triggers"], + }) + .input(createAITriggerRequest) + .output(aiTriggerResource) + .errors(possibleErrorsOnCreatingResource) + .handler( + async ({ context, input }) => + await aiTriggerService.create({ + workspaceId: context.workspace.id, + data: input, + }), + ), + + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/ai-triggers/{id}", + summary: "Update an AI trigger", + tags: ["AI Triggers"], + }) + .input(updateAITriggerRequest.and(z.object({ id: zodBigintAsString() }))) + .output(aiTriggerResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...data } = input + return await aiTriggerService.update( + { workspaceId: context.workspace.id, id }, + data, + ) + }), + + duplicate: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ai-triggers/{id}/duplicate", + summary: "Duplicate an AI trigger", + successStatus: 201, + tags: ["AI Triggers"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(aiTriggerResource) + .errors(possibleErrorsOnMutatingResource) + .handler( + async ({ context, input }) => + await aiTriggerService.duplicate({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/ai-triggers/{id}", + summary: "Delete an AI trigger", + successStatus: 204, + tags: ["AI Triggers"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await aiTriggerService.deleteMany({ + workspaceId: context.workspace.id, + ids: [input.id], + }) + }), +} diff --git a/apps/builder/src/features/ai-triggers/queries/index.ts b/apps/builder/src/features/ai-triggers/queries/index.ts new file mode 100644 index 0000000000..795ae56fed --- /dev/null +++ b/apps/builder/src/features/ai-triggers/queries/index.ts @@ -0,0 +1,14 @@ +import { aiTriggerService } from "@chatbotx.io/business" +import type { + AITriggerCollection, + ListAITriggersRequest, +} from "@/features/ai-triggers/schema/query" +import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" + +export const listAITriggers = async ( + input: ListAITriggersRequest, +): Promise => { + await assertCurrentUserCanAccessChatbot(input.workspaceId) + + return await aiTriggerService.list(input) +} diff --git a/apps/builder/src/features/ai-triggers/schema/resource.ts b/apps/builder/src/features/ai-triggers/schema/resource.ts new file mode 100644 index 0000000000..32f01ba632 --- /dev/null +++ b/apps/builder/src/features/ai-triggers/schema/resource.ts @@ -0,0 +1,12 @@ +import { + aiTriggerModel, + createSelectSchema, +} from "@chatbotx.io/database/schema" +import { z } from "zod" + +export const aiTriggerResource = createSelectSchema(aiTriggerModel, { + id: z.string(), + workspaceId: z.string(), + flowId: z.string().nullable(), +}) +export type AITriggerResource = z.infer diff --git a/apps/builder/src/features/ai-triggers/table.tsx b/apps/builder/src/features/ai-triggers/table.tsx index 86dba63190..d064132327 100644 --- a/apps/builder/src/features/ai-triggers/table.tsx +++ b/apps/builder/src/features/ai-triggers/table.tsx @@ -11,8 +11,8 @@ import { useAction } from "next-safe-action/hooks" import { use, useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { duplicateAITriggerAction } from "@/features/ai-triggers/actions/duplicate.action" -import type { listAITriggers } from "@/features/ai-triggers/actions/list.action" import { DeleteAITriggerDialog } from "@/features/ai-triggers/delete" +import type { listAITriggers } from "@/features/ai-triggers/queries" import { AITriggersTableToolbarActions } from "@/features/ai-triggers/table-toolbar-actions" import { getAITriggersColumns } from "./table-columns" diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index fa6b988141..b57e3be58d 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -1,5 +1,14 @@ import { automatedResponseService } from "@chatbotx.io/business" -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { automatedResponseTypes } from "@chatbotx.io/database/partials" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { publicKeywordResource } from "../schema/resource" @@ -14,19 +23,134 @@ export const keywordsPublicRouter = { summary: "List keywords (automated responses)", tags: ["Keywords"], }) - .input(publicListRequest) + .input( + publicListRequest.extend({ + type: automatedResponseTypes.default("inbound"), + }), + ) .output(publicListResponse(publicKeywordResource)) .errors(possibleErrorsOnListingResource) .handler(async ({ context, input }) => { - const result = await automatedResponseService.list({ + const { type, ...pagination } = input + return await automatedResponseService.list({ workspaceId: context.workspace.id, - type: "inbound", - ...input, + type, + ...pagination, sort: [{ id: "createdAt", desc: true }], keyword: null, folderId: null, }) + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/keywords/{id}", + summary: "Get a keyword automation by id", + tags: ["Keywords"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(publicKeywordResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await automatedResponseService.findOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/keywords", + summary: "Create a keyword automation", + successStatus: 201, + tags: ["Keywords"], + }) + .input( + z.object({ + type: automatedResponseTypes.default("inbound"), + keywords: z.array(z.string().min(1).max(255)).min(1), + text: z.string().min(1).nullish(), + flowId: zodBigintAsString().nullish(), + folderId: zodBigintAsString().nullish(), + }), + ) + .output(publicKeywordResource) + .errors(possibleErrorsOnCreatingResource) + .handler( + async ({ context, input }) => + await automatedResponseService.create(context.workspace.id, input), + ), - return result + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/keywords/{id}", + summary: "Update a keyword automation", + tags: ["Keywords"], + }) + .input( + z.object({ + id: zodBigintAsString(), + keywords: z.array(z.string().min(1).max(255)).min(1).optional(), + text: z.string().min(1).nullish(), + flowId: zodBigintAsString().nullish(), + folderId: zodBigintAsString().nullish(), + }), + ) + .output(publicKeywordResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, keywords, ...rest } = input + await automatedResponseService.findOrFail({ + workspaceId: context.workspace.id, + id, + }) + return await automatedResponseService.update( + { workspaceId: context.workspace.id, id }, + { + ...rest, + keywords: keywords?.map((value) => ({ value })), + }, + ) + }), + + updateStatus: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/keywords/{id}/status", + summary: "Enable or disable a keyword automation", + tags: ["Keywords"], + }) + .input(z.object({ id: zodBigintAsString(), status: z.boolean() })) + .output(publicKeywordResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + await automatedResponseService.findOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }) + return await automatedResponseService.setStatus( + { workspaceId: context.workspace.id, id: input.id }, + input.status, + ) + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/keywords/{id}", + summary: "Delete a keyword automation", + successStatus: 204, + tags: ["Keywords"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await automatedResponseService.deleteMany(context.workspace.id, [ + input.id, + ]) }), } diff --git a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts index 8feb75b25a..943745045f 100644 --- a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts +++ b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts @@ -1,14 +1,11 @@ "use server" import { botFieldService } from "@chatbotx.io/business" -import { isDatabaseError } from "@chatbotx.io/database/client" import { returnValidationErrors } from "next-safe-action" import { workspaceIdrequestParams } from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" import { createBotFieldRequest } from "../schema/action" -const UNIQUE_VIOLATION_CODE = "23505" - export const createBotFieldAction = workspaceActionClient .inputSchema(createBotFieldRequest) .bindArgsSchemas(workspaceIdrequestParams) @@ -24,8 +21,9 @@ export const createBotFieldAction = workspaceActionClient // Unique (workspaceId, type, name) — surface a field-level error under // Name instead of the generic toast (mirrors createCustomFieldAction). if ( - isDatabaseError(error) && - error.cause.code === UNIQUE_VIOLATION_CODE + error instanceof Error && + "code" in error && + error.code === "validation" ) { return returnValidationErrors(createBotFieldRequest, { _errors: ["Validation Exception"], diff --git a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts index e4ffc63111..f0b470ee40 100644 --- a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts +++ b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts @@ -1,7 +1,6 @@ "use server" import { botFieldService } from "@chatbotx.io/business" -import { isDatabaseError } from "@chatbotx.io/database/client" import { returnValidationErrors } from "next-safe-action" import { type WorkspaceIdAndIdRequestParams, @@ -33,7 +32,11 @@ export const updateBotFieldAction = workspaceActionClient } catch (error) { // Renaming into an existing (type, name) hits the same unique index // as create — surface it under the Name field, not a generic toast. - if (isDatabaseError(error) && error.cause.code === "23505") { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" + ) { return returnValidationErrors(updateBotFieldRequest, { _errors: ["Validation Exception"], name: { _errors: ["Name is already taken"] }, diff --git a/apps/builder/src/features/broadcasts/actions/update-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/update-broadcast.action.ts index 878157e4b3..95b58de42d 100644 --- a/apps/builder/src/features/broadcasts/actions/update-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/update-broadcast.action.ts @@ -1,14 +1,9 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { broadcastModel } from "@chatbotx.io/database/schema" +import { broadcastService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { - type UpdateBroadcastSchema, - updateBroadcastSchema, -} from "../schema/action" +import { updateBroadcastSchema } from "../schema/action" export const updateBroadcastAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -19,30 +14,5 @@ export const updateBroadcastAction = workspaceActionClient parsedInput, } = props - return await updateBroadcast({ workspaceId, id }, parsedInput) + await broadcastService.update({ workspaceId, id }, parsedInput) }) - -export const updateBroadcast = async ( - ctx: { workspaceId: string; id: string }, - parsedInput: UpdateBroadcastSchema, -) => { - const broadcast = await findOrFail({ - table: broadcastModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - deletedAt: { isNull: true }, - }, - }) - - await db - .update(broadcastModel) - .set(parsedInput) - .where(eq(broadcastModel.id, broadcast.id)) - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail: `updated a broadcast (#${broadcast.id})`, - }) -} diff --git a/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts b/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts index d7edd6b190..a0b02e8168 100644 --- a/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts +++ b/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts @@ -1,13 +1,9 @@ "use server" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { flowVersionModel } from "@chatbotx.io/database/schema" +import { flowVersionService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { - type UpdateDraftFlowVersionSchema, - updateDraftFlowVersionSchema, -} from "../schema/action" +import { updateDraftFlowVersionSchema } from "../schema/action" export const updateDraftFlowVersionAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -18,32 +14,11 @@ export const updateDraftFlowVersionAction = workspaceActionClient parsedInput, } = props - await updateDraftFlowVersion({ workspaceId, id }, parsedInput) - return { ok: true as const } - }) - -export const updateDraftFlowVersion = async ( - ctx: { - workspaceId: string - id: string - }, - parsedInput: UpdateDraftFlowVersionSchema, -) => { - const flowVersion = await findOrFail({ - table: flowVersionModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - isDraft: true, - }, - message: "Draft flow version not found", - }) - - await db - .update(flowVersionModel) - .set({ + await flowVersionService.updateDraft({ + workspaceId, + id, nodes: parsedInput.nodes, edges: parsedInput.edges, }) - .where(eq(flowVersionModel.id, flowVersion.id)) -} + return { ok: true as const } + }) diff --git a/apps/builder/src/features/flows/actions/update-flow-action.ts b/apps/builder/src/features/flows/actions/update-flow-action.ts index 73c6fdfa0b..1d63afbab6 100644 --- a/apps/builder/src/features/flows/actions/update-flow-action.ts +++ b/apps/builder/src/features/flows/actions/update-flow-action.ts @@ -1,11 +1,9 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { flowModel } from "@chatbotx.io/database/schema" +import { flowService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { type UpdateFlowSchema, updateFlowSchema } from "../schema/action" +import { updateFlowSchema } from "../schema/action" export const updateFlowAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -16,46 +14,5 @@ export const updateFlowAction = workspaceActionClient parsedInput, } = props - await updateFlow({ workspaceId, id }, parsedInput) + await flowService.update({ workspaceId, id }, parsedInput) }) - -const updateFlow = async ( - ctx: { - workspaceId: string - id: string - }, - parsedInput: UpdateFlowSchema, -) => { - const flow = await findOrFail({ - table: flowModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Flow not found", - }) - - const hasChanges = Object.entries(parsedInput).some( - ([key, value]) => flow[key as keyof UpdateFlowSchema] !== value, - ) - - if (!hasChanges) { - return - } - - const updated = await db - .update(flowModel) - .set(parsedInput) - .where(eq(flowModel.id, flow.id)) - .returning({ id: flowModel.id }) - - if (updated.length === 0) { - return - } - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail: `updated a flow (#${flow.id})`, - }) -} diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index 319b253734..2a69715f42 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -1,8 +1,29 @@ -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { + flowService, + flowVersionService, + importService, +} from "@chatbotx.io/business" +import { validationException } from "@chatbotx.io/business/errors" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { DefaultJobAction, defaultQueue } from "@chatbotx.io/worker-config" +import { z } from "zod" +import { flowVersionResource } from "@/features/flow-versions/schema/resource" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { listFlows } from "../queries" -import { flowResource } from "../schema/resource" +import { + createFlowSchema, + publishFlowSchema, + updateDraftFlowVersionSchema, + updateFlowSchema, +} from "../schema/action" +import { flowWithVersionsResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") @@ -11,18 +32,216 @@ export const flowsPublicRouter = { .route({ method: "GET", path: "/v1/flows", - summary: "Get all flows", + summary: "List flows", + description: + "Lists flows in the workspace. Omit `active` to return both active and inactive flows.", tags: ["Flows"], }) - .input(publicListRequest) - .output(publicListResponse(flowResource.pick({ id: true, name: true }))) + .input(publicListRequest.extend({ active: z.boolean().optional() })) + .output(publicListResponse(flowWithVersionsResource)) .errors(possibleErrorsOnListingResource) .handler( async ({ context, input }) => - await listFlows({ + await flowService.list({ ...input, workspaceId: context.workspace.id, - active: true, }), ), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/flows/{id}", + summary: "Get a flow by id", + description: "Returns a flow with its list of versions.", + tags: ["Flows"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(flowWithVersionsResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await flowService.findById({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/flows", + summary: "Create a flow", + description: + "Creates a new draft flow seeded with a single default start node.", + successStatus: 201, + tags: ["Flows"], + }) + .input(createFlowSchema) + .output(z.object({ id: z.string() })) + .errors(possibleErrorsOnCreatingResource) + .handler( + async ({ context, input }) => + await flowService.createDraft({ + workspaceId: context.workspace.id, + data: input, + }), + ), + + update: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/flows/{id}", + summary: "Update flow settings", + description: + "Partially updates a flow's name, active, or enableInInbox flags.", + tags: ["Flows"], + }) + .input(updateFlowSchema.and(z.object({ id: zodBigintAsString() }))) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...data } = input + await flowService.update({ workspaceId: context.workspace.id, id }, data) + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/flows/{id}", + summary: "Delete a flow", + successStatus: 204, + tags: ["Flows"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await flowService.deleteMany({ + workspaceId: context.workspace.id, + ids: [input.id], + }) + }), + + duplicate: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/flows/{id}/duplicate", + summary: "Duplicate a flow", + description: "Duplicates a flow's draft version into a new flow.", + successStatus: 201, + tags: ["Flows"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(z.object({ id: z.string() })) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const id = await flowService.duplicate({ + workspaceId: context.workspace.id, + id: input.id, + }) + return { id } + }), + + publish: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/flows/{id}/publish", + summary: "Publish a flow", + description: + "Publishes the given nodes/edges as a new immutable version and syncs the draft to match.", + tags: ["Flows"], + }) + .input(publishFlowSchema.and(z.object({ id: zodBigintAsString() }))) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, nodes, edges } = input + await flowVersionService.publish({ + workspaceId: context.workspace.id, + flowId: id, + nodes, + edges, + }) + }), + + updateDraft: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/flows/{id}/draft", + summary: "Update a flow's draft version", + description: + "Overwrites the draft version's nodes/edges in place, without publishing.", + tags: ["Flows"], + }) + .input( + updateDraftFlowVersionSchema.and(z.object({ id: zodBigintAsString() })), + ) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, nodes, edges } = input + await flowVersionService.updateDraft({ + workspaceId: context.workspace.id, + id, + nodes, + edges, + }) + }), + + versions: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/flows/{id}/versions", + summary: "List a flow's published versions", + tags: ["Flows"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(z.object({ data: z.array(flowVersionResource) })) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const data = await flowVersionService.list({ + flowId: input.id, + workspaceId: context.workspace.id, + }) + return { data } + }), + + import: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/flows/import", + summary: "Import a flow from a previously uploaded file", + description: + "Queues an async import job for a flow export file uploaded via the Files API. Returns the import id; poll or watch for completion out of band.", + successStatus: 202, + tags: ["Flows"], + }) + .input( + z.object({ + fileId: zodBigintAsString(), + folderId: zodBigintAsString().nullable(), + }), + ) + .output(z.object({ importId: z.string() })) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + const result = await importService.startFlowImport({ + workspaceId: context.workspace.id, + userId: context.workspace.ownerId, + fileId: input.fileId, + folderId: input.folderId, + }) + if (!result.ok) { + throw validationException( + "fileId", + result.reason === "fileNotFound" + ? "File not found" + : "File is not a flow import", + ) + } + + await defaultQueue.add(DefaultJobAction.runImport, { + type: DefaultJobAction.runImport, + data: { importId: result.importId }, + }) + + return { importId: result.importId } + }), } diff --git a/apps/builder/src/features/flows/queries/index.ts b/apps/builder/src/features/flows/queries/index.ts index b3301d4305..697e93934b 100644 --- a/apps/builder/src/features/flows/queries/index.ts +++ b/apps/builder/src/features/flows/queries/index.ts @@ -1,16 +1,5 @@ import { flowService } from "@chatbotx.io/business" -import { notFoundException } from "@chatbotx.io/business/errors" -import { - flowRepository, - whatsappMessageTemplateRepository, -} from "@chatbotx.io/database/repositories" -import { parsePagination } from "@chatbotx.io/database/utils" -import { stepTypes } from "@chatbotx.io/flow-config" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" -import { - filterFlowsByStartStepType, - filterFlowsByTemplateIds, -} from "../actions/filter-flow-action" import type { FindFlowParams, ListFlowsRequest, @@ -29,34 +18,7 @@ export const listFlowsRSC = async ( export async function listFlows( input: ListFlowsRequest & { workspaceId: string }, ): Promise { - const pagination = parsePagination(input) - - let [data, total] = await Promise.all([ - flowRepository.listWithVersions(input), - flowRepository.count(input), - ]) - - if (input.startType) { - data = filterFlowsByStartStepType(data, input.startType) - - if (input.startType === stepTypes.enum.sendWaTemplateMessage) { - if (input.integrationWhatsappId) { - const templateIds = - await whatsappMessageTemplateRepository.listIdsByIntegration({ - integrationWhatsappId: input.integrationWhatsappId, - }) - data = filterFlowsByTemplateIds(data, templateIds) - } else { - data = [] - } - } - - total = data.length - } - - const pageCount = pagination?.limit ? Math.ceil(total / pagination.limit) : 1 - - return { data, pageCount, ...pagination } + return await flowService.list(input) } export const findFlow = async ( @@ -64,10 +26,7 @@ export const findFlow = async ( ): Promise<{ data: FlowResource | null }> => { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const targetFlow = await flowRepository.findWithVersions(input) - if (!targetFlow) { - throw notFoundException("Flow does not exists.") - } + const targetFlow = await flowService.findById(input) return { data: targetFlow } } diff --git a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts index 10b30c76f5..b498fcc3c9 100644 --- a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts +++ b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts @@ -1,8 +1,6 @@ "use server" -import { db, isUniqueViolationError } from "@chatbotx.io/database/client" -import { reflinkModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" +import { reflinkService } from "@chatbotx.io/business" import { returnValidationErrors } from "next-safe-action" import { type WorkspaceIdRequestParams, @@ -26,14 +24,13 @@ export const createReflinkAction = workspaceActionClient parsedInput: CreateReflinkRequest }) => { try { - await db.insert(reflinkModel).values({ - id: createId(), - workspaceId, - type: "refLink", - ...parsedInput, - }) + await reflinkService.create({ workspaceId, data: parsedInput }) } catch (error) { - if (isUniqueViolationError(error)) { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" + ) { return returnValidationErrors(createReflinkRequest, { _errors: ["Validation Exception"], name: { _errors: ["Name is already taken"] }, diff --git a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts index 721dd7b439..84bb9a8d80 100644 --- a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts +++ b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts @@ -1,20 +1,10 @@ "use server" -import { - and, - db, - eq, - findOrFail, - isUniqueViolationError, -} from "@chatbotx.io/database/client" -import { reflinkModel } from "@chatbotx.io/database/schema" +import { reflinkService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { returnValidationErrors } from "next-safe-action" import { workspaceActionClient } from "@/lib/safe-action" -import { - type UpdateReflinkRequest, - updateReflinkRequest, -} from "../schema/action" +import { updateReflinkRequest } from "../schema/action" export const updateReflinkAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -25,43 +15,20 @@ export const updateReflinkAction = workspaceActionClient parsedInput, } = props - return await updateReflink( - { - workspaceId, - id, - }, - parsedInput, - ) - }) + try { + await reflinkService.update({ workspaceId, id }, parsedInput) + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" + ) { + return returnValidationErrors(updateReflinkRequest, { + _errors: ["Validation Exception"], + name: { _errors: ["Name is already taken"] }, + }) + } -export const updateReflink = async ( - ctx: { - workspaceId: string - id: string - }, - parsedInput: UpdateReflinkRequest, -) => { - const reflink = await findOrFail({ - table: reflinkModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Reflink not found", - }) - try { - await db - .update(reflinkModel) - .set(parsedInput) - .where(and(eq(reflinkModel.id, reflink.id))) - } catch (error) { - if (isUniqueViolationError(error)) { - return returnValidationErrors(updateReflinkRequest, { - _errors: ["Validation Exception"], - name: { _errors: ["Name is already taken"] }, - }) + throw error } - - throw error - } -} + }) diff --git a/apps/builder/src/features/reflinks/api/public.ts b/apps/builder/src/features/reflinks/api/public.ts index 401946c758..9ab9ef5e42 100644 --- a/apps/builder/src/features/reflinks/api/public.ts +++ b/apps/builder/src/features/reflinks/api/public.ts @@ -1,14 +1,39 @@ -import { notFoundException } from "@chatbotx.io/business/errors" +import { reflinkService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" -import { possibleErrorsOnFindingResource } from "@/lib/orpc/orpc-error-helper" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { findReflink } from "../queries" +import { createReflinkRequest, updateReflinkRequest } from "../schema/action" import { reflinkResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") export const reflinksPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ref-links", + summary: "List ref links", + tags: ["Ref Links"], + }) + .input(publicListRequest) + .output(publicListResponse(reflinkResource)) + .errors(possibleErrorsOnListingResource) + .handler( + async ({ context, input }) => + await reflinkService.list({ + ...input, + workspaceId: context.workspace.id, + }), + ), + get: workspaceTokenAuthAPI .route({ method: "GET", @@ -19,14 +44,65 @@ export const reflinksPublicRouter = { .input(z.object({ id: zodBigintAsString() })) .output(reflinkResource) .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await reflinkService.findOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ref-links", + summary: "Create a ref link", + successStatus: 201, + tags: ["Ref Links"], + }) + .input(createReflinkRequest) + .output(reflinkResource) + .errors(possibleErrorsOnCreatingResource) + .handler( + async ({ context, input }) => + await reflinkService.create({ + workspaceId: context.workspace.id, + data: input, + }), + ), + + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/ref-links/{id}", + summary: "Update a ref link", + tags: ["Ref Links"], + }) + .input(updateReflinkRequest.and(z.object({ id: zodBigintAsString() }))) + .output(reflinkResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...data } = input + return await reflinkService.update( + { workspaceId: context.workspace.id, id }, + data, + ) + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/ref-links/{id}", + summary: "Delete a ref link", + successStatus: 204, + tags: ["Ref Links"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { - const reflink = await findReflink({ + await reflinkService.deleteMany({ workspaceId: context.workspace.id, - id: input.id, + ids: [input.id], }) - if (!reflink) { - throw notFoundException("Ref link not found") - } - return reflink }), } diff --git a/apps/builder/src/features/reflinks/queries/index.ts b/apps/builder/src/features/reflinks/queries/index.ts index 19d03e9112..43b5e273f8 100644 --- a/apps/builder/src/features/reflinks/queries/index.ts +++ b/apps/builder/src/features/reflinks/queries/index.ts @@ -1,10 +1,4 @@ -import { db, relationsFilterToSQL } from "@chatbotx.io/database/client" -import { reflinkModel } from "@chatbotx.io/database/schema" -import { - getPaginationWithDefaults, - likeContains, - parseOrderByAsObject, -} from "@chatbotx.io/database/utils" +import { reflinkService } from "@chatbotx.io/business" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { GetReflinkRequest, @@ -18,37 +12,11 @@ export async function listReflinks( ): Promise { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const where = { - workspaceId: input.workspaceId, - type: "refLink" as const, - ...(input.keyword ? { name: { ilike: likeContains(input.keyword) } } : {}), - } - - const pagination = getPaginationWithDefaults(input) - const orderBy = parseOrderByAsObject(reflinkModel, input) - - const [data, totalRows] = await Promise.all([ - db.query.reflinkModel.findMany({ - where, - orderBy, - ...pagination, - with: { - flow: true, - customField: true, - }, - }), - db.$count(reflinkModel, relationsFilterToSQL(reflinkModel, where)), - ]) - - const pageCount = Math.ceil(totalRows / input.perPage) - - return { data, pageCount } + return await reflinkService.list(input) } export async function findReflink( where: GetReflinkRequest, ): Promise { - return await db.query.reflinkModel.findFirst({ - where: { ...where, type: "refLink" }, - }) + return await reflinkService.findOrFail(where).catch(() => undefined) } diff --git a/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts b/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts index 3f1f727a92..9a02047ba3 100644 --- a/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts +++ b/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts @@ -1,8 +1,6 @@ "use server" -import { db } from "@chatbotx.io/database/client" -import { savedReplyModel } from "@chatbotx.io/database/schema" -import { createId } from "@chatbotx.io/utils" +import { savedReplyService } from "@chatbotx.io/business" import { workspaceIdrequestParams } from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" import { createSavedReplyRequest } from "../schema/mutation" @@ -15,16 +13,10 @@ export const createSavedReplyAction = workspaceActionClient bindArgsParsedInputs: [workspaceId], parsedInput, } = props - const savedReply = await db - .insert(savedReplyModel) - .values({ - id: createId(), - workspaceId, - shortcut: parsedInput.shortcut, - text: parsedInput.text, - }) - .returning() - .then((result) => result[0]) - return savedReply + return await savedReplyService.create({ + workspaceId, + shortcut: parsedInput.shortcut, + text: parsedInput.text, + }) }) diff --git a/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts b/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts index 7dd93d9f56..ff6b584b5e 100644 --- a/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts +++ b/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts @@ -1,7 +1,6 @@ "use server" -import { db, eq, findOrFail } from "@chatbotx.io/database/client" -import { savedReplyModel } from "@chatbotx.io/database/schema" +import { savedReplyService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" import { editSavedReplyRequest } from "../schema/mutation" @@ -15,22 +14,11 @@ export const editSavedReplyAction = workspaceActionClient parsedInput, } = props - const savedReply = await findOrFail({ - table: savedReplyModel, - where: { - id, - workspaceId, - }, - message: "Saved reply not found", - }) - const [updatedSavedReply] = await db - .update(savedReplyModel) - .set({ + return await savedReplyService.update( + { workspaceId, id }, + { shortcut: parsedInput.shortcut, text: parsedInput.text, - }) - .where(eq(savedReplyModel.id, savedReply.id)) - .returning() - - return updatedSavedReply + }, + ) }) diff --git a/apps/builder/src/features/sequences/actions/update-sequence.action.ts b/apps/builder/src/features/sequences/actions/update-sequence.action.ts index 89c44d8259..8af5085e50 100644 --- a/apps/builder/src/features/sequences/actions/update-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/update-sequence.action.ts @@ -1,22 +1,11 @@ "use server" -import { auditService } from "@chatbotx.io/business/audit" -import { - and, - db, - eq, - findOrFail, - isDatabaseError, -} from "@chatbotx.io/database/client" -import { sequenceModel } from "@chatbotx.io/database/schema" +import { sequenceService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { workspaceActionClient } from "@/lib/safe-action" -import { - type UpdateSequenceSchema, - updateSequenceSchema, -} from "../schema/action" +import { updateSequenceSchema } from "../schema/action" export const updateSequenceAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) @@ -27,75 +16,24 @@ export const updateSequenceAction = workspaceActionClient parsedInput, } = props - return await updateSequence( - { - workspaceId, - id, - }, - parsedInput, - ) - }) - -export const updateSequence = async ( - ctx: { - workspaceId: string - id: string - }, - parsedInput: UpdateSequenceSchema, -) => { - const t = await getTranslations() - - const sequence = await findOrFail({ - table: sequenceModel, - where: { - id: ctx.id, - workspaceId: ctx.workspaceId, - }, - message: "Sequence not found", - }) - - try { - const changedEntries = Object.entries(parsedInput).filter( - ([key, value]) => sequence[key as keyof UpdateSequenceSchema] !== value, - ) - - if (changedEntries.length === 0) { - return - } - - const updated = await db - .update(sequenceModel) - .set(parsedInput) - .where(and(eq(sequenceModel.id, ctx.id))) - .returning({ id: sequenceModel.id }) - - if (updated.length === 0) { - return - } - - const changedKeys = changedEntries.map(([key]) => key) - let detail = `updated a sequence (#${sequence.id})` - if (changedKeys.length === 1 && changedKeys[0] === "active") { - detail = parsedInput.active - ? `enabled a sequence (#${sequence.id})` - : `disabled a sequence (#${sequence.id})` + const t = await getTranslations() + + try { + await sequenceService.update({ workspaceId, id }, parsedInput) + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "validation" + ) { + return returnValidationErrors(updateSequenceSchema, { + _errors: [t("sequences.validation.exception")], + name: { + _errors: [t("sequences.validation.nameExists")], + }, + }) + } + + throw new Error("Failed to update sequence") } - - await auditService.record({ - workspaceId: ctx.workspaceId, - action: "update", - detail, - }) - } catch (error) { - if (isDatabaseError(error) && error.cause.code === "23505") { - return returnValidationErrors(updateSequenceSchema, { - _errors: [t("sequences.validation.exception")], - name: { - _errors: [t("sequences.validation.nameExists")], - }, - }) - } - - throw new Error("Failed to update sequence") - } -} + }) diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 1e78a34283..63b77846db 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -1,22 +1,51 @@ import { triggerService } from "@chatbotx.io/business" -import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { notFoundException } from "@chatbotx.io/business/errors" +import { folderTypes } from "@chatbotx.io/database/partials" +import { triggerRepository } from "@chatbotx.io/database/repositories" +import type { TriggerModel } from "@chatbotx.io/database/types" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { toConditionColumns } from "@/features/conditions/to-condition-columns" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" import { paginateInMemory, publicListRequest, publicListResponse, } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" - +import { createTriggerSchema, updateTriggerSchema } from "../schema/mutation" import { triggerResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") +type ConditionRow = { + id: string + type: string + sourceId: string | null + operator: string | null + value: unknown +} + +const toResource = ( + trigger: TriggerModel & { conditions?: ConditionRow[] }, +) => ({ + ...trigger, + conditions: trigger.conditions ?? [], +}) + export const triggersPublicRouter = { list: workspaceTokenAuthAPI .route({ method: "GET", path: "/v1/triggers", summary: "List triggers", + description: "Lists triggers with their real conditions and actions.", tags: ["Triggers"], }) .input(publicListRequest) @@ -26,13 +55,136 @@ export const triggersPublicRouter = { const triggers = await triggerService.listByWorkspaceId( context.workspace.id, ) + const withConditions = await Promise.all( + triggers.map((trigger) => + triggerRepository.findWithConditions({ + id: trigger.id, + workspaceId: context.workspace.id, + }), + ), + ) return paginateInMemory( - triggers.map((trigger) => ({ - ...trigger, - conditions: [], - actions: [], - })), + withConditions.filter((trigger) => trigger !== null).map(toResource), input, ) }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/triggers/{id}", + summary: "Get a trigger by id", + description: "Returns a trigger with its real conditions and actions.", + tags: ["Triggers"], + }) + .input(z.object({ id: zodBigintAsString() })) + .output(triggerResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const trigger = await triggerRepository.findWithConditions({ + id: input.id, + workspaceId: context.workspace.id, + }) + if (!trigger) { + throw notFoundException("Trigger not found") + } + return toResource(trigger) + }), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/triggers", + summary: "Create a trigger", + description: + "Creates an empty trigger. Use PUT /v1/triggers/{id} to attach conditions and actions.", + successStatus: 201, + tags: ["Triggers"], + }) + .input(createTriggerSchema) + .output(triggerResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + const created = await triggerService.create({ + workspaceId: context.workspace.id, + data: input, + folderType: folderTypes.enum.trigger, + }) + return toResource(created) + }), + + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/triggers/{id}", + summary: "Replace a trigger's conditions and actions", + tags: ["Triggers"], + }) + .input(updateTriggerSchema.and(z.object({ id: zodBigintAsString() }))) + .output(triggerResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, conditions, actions } = input + const updated = await triggerService.updateWithConditions({ + workspaceId: context.workspace.id, + id, + actions, + conditions: conditions.map((condition) => ({ + id: "id" in condition ? condition.id : undefined, + ...toConditionColumns(condition), + })), + }) + if (!updated) { + throw notFoundException("Trigger not found") + } + const withConditions = await triggerRepository.findWithConditions({ + id, + workspaceId: context.workspace.id, + }) + if (!withConditions) { + throw notFoundException("Trigger not found") + } + return toResource(withConditions) + }), + + updateSettings: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/triggers/{id}/settings", + summary: "Update a trigger's name or active state", + tags: ["Triggers"], + }) + .input( + z.object({ + id: zodBigintAsString(), + name: z.string().trim().min(1).max(255).optional(), + active: z.boolean().optional(), + }), + ) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...patch } = input + await triggerService.updateSettings({ + workspaceId: context.workspace.id, + id, + ...patch, + }) + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/triggers/{id}", + summary: "Delete a trigger", + successStatus: 204, + tags: ["Triggers"], + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await triggerService.deleteMany({ + workspaceId: context.workspace.id, + ids: [input.id], + }) + }), } diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index 47826448b2..24ee383957 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -1,5 +1,6 @@ import { inboxTeamsPublicRouter } from "@/enterprise/features/inbox-teams/api/public" import { aiAgentsPublicRouter } from "@/features/ai-agents/api/public" +import { aiTriggersPublicRouter } from "@/features/ai-triggers/api/public" import { keywordsPublicRouter } from "@/features/automated-response/api/public" import { botFieldsPublicRouter } from "@/features/bot-fields/api/public" import { broadcastsPublicRouter } from "@/features/broadcasts/api/public" @@ -27,6 +28,7 @@ import { workspaceMembersPublicRouter } from "@/features/workspace-members/api/p export const publicRouter = { aiAgents: aiAgentsPublicRouter, + aiTriggers: aiTriggersPublicRouter, botFields: botFieldsPublicRouter, broadcasts: broadcastsPublicRouter, channels: channelsPublicRouter, diff --git a/docs/developer/workspace-api-tokens.md b/docs/developer/workspace-api-tokens.md index 618f559664..8545bbed90 100644 --- a/docs/developer/workspace-api-tokens.md +++ b/docs/developer/workspace-api-tokens.md @@ -134,6 +134,69 @@ enforces this split — it fails compile/test if a new submodule (wherever it lives) forgets to declare a scope, or if `messages.ts`'s procedures drift onto `contacts`. +### Automation scope — endpoint-to-scope table + +The `automation` scope covers flows, triggers, keywords (automated +responses), AI agents, ref links, and AI triggers — a full CRUD surface so an +agent can build, publish, and inspect automations without human help via the +builder UI. Every handler below calls the same `packages/business` service +method the corresponding UI action calls (`.agents/rules/data-access.md`). + +| Resource | Endpoint | Service method | +| --- | --- | --- | +| Flows | `GET /v1/flows` | `flowService.list` | +| Flows | `GET /v1/flows/{id}` | `flowService.findById` | +| Flows | `POST /v1/flows` | `flowService.createDraft` | +| Flows | `PATCH /v1/flows/{id}` | `flowService.update` | +| Flows | `DELETE /v1/flows/{id}` | `flowService.deleteMany` | +| Flows | `POST /v1/flows/{id}/duplicate` | `flowService.duplicate` | +| Flows | `POST /v1/flows/{id}/publish` | `flowVersionService.publish` | +| Flows | `PUT /v1/flows/{id}/draft` | `flowVersionService.updateDraft` | +| Flows | `GET /v1/flows/{id}/versions` | `flowVersionService.list` | +| Flows | `POST /v1/flows/import` | `importService.startFlowImport` | +| Triggers | `GET /v1/triggers` | `triggerService.listByWorkspaceId` + `triggerRepository.findWithConditions` | +| Triggers | `GET /v1/triggers/{id}` | `triggerRepository.findWithConditions` | +| Triggers | `POST /v1/triggers` | `triggerService.create` | +| Triggers | `PUT /v1/triggers/{id}` | `triggerService.updateWithConditions` | +| Triggers | `PATCH /v1/triggers/{id}/settings` | `triggerService.updateSettings` | +| Triggers | `DELETE /v1/triggers/{id}` | `triggerService.deleteMany` | +| Keywords | `GET /v1/keywords` | `automatedResponseService.list` | +| Keywords | `GET /v1/keywords/{id}` | `automatedResponseService.findOrFail` | +| Keywords | `POST /v1/keywords` | `automatedResponseService.create` | +| Keywords | `PUT /v1/keywords/{id}` | `automatedResponseService.update` | +| Keywords | `PATCH /v1/keywords/{id}/status` | `automatedResponseService.setStatus` | +| Keywords | `DELETE /v1/keywords/{id}` | `automatedResponseService.deleteMany` | +| AI agents | `GET /v1/ai-agents` | `aiAgentService.listAIAgents` | +| AI agents | `GET /v1/ai-agents/{id}` | `aiAgentService.findBy` | +| AI agents | `POST /v1/ai-agents` | `aiAgentService.create` | +| AI agents | `PUT /v1/ai-agents/{id}` | `aiAgentService.updateAIAgent` | +| AI agents | `DELETE /v1/ai-agents/{id}` | `aiAgentService.delete` | +| Ref links | `GET /v1/ref-links` | `reflinkService.list` | +| Ref links | `GET /v1/ref-links/{id}` | `reflinkService.findOrFail` | +| Ref links | `POST /v1/ref-links` | `reflinkService.create` | +| Ref links | `PUT /v1/ref-links/{id}` | `reflinkService.update` | +| Ref links | `DELETE /v1/ref-links/{id}` | `reflinkService.deleteMany` | +| AI triggers | `GET /v1/ai-triggers` | `aiTriggerService.list` | +| AI triggers | `GET /v1/ai-triggers/{id}` | `aiTriggerService.findOrFail` | +| AI triggers | `POST /v1/ai-triggers` | `aiTriggerService.create` | +| AI triggers | `PUT /v1/ai-triggers/{id}` | `aiTriggerService.update` | +| AI triggers | `POST /v1/ai-triggers/{id}/duplicate` | `aiTriggerService.duplicate` | +| AI triggers | `DELETE /v1/ai-triggers/{id}` | `aiTriggerService.deleteMany` | + +Two invariants to preserve when touching this surface: + +- **Keywords `type` filter** — `AutomatedResponse` serves two `FolderType`s + off one table (`automatedResponse` for inbound/Contact, + `outboundAutomatedResponse` for outbound/Page), disambiguated by the `type` + column (invariant #17 in the root `AGENTS.md`). `type` must stay in the + where-clause on every keywords path — never let it become fully optional + in a way that drops the filter. +- **`GET /v1/triggers` and `GET /v1/triggers/{id}` return real conditions and + actions**, not the empty arrays the routes returned before this scope was + widened. Any future trigger route must keep populating both via + `triggerRepository.findWithConditions` rather than reintroducing a + hardcoded `[]`. + ## Adding a new scope value 1. Add the value to `workspaceApiTokenScopes` in diff --git a/packages/business/__tests__/ai-trigger-service.test.ts b/packages/business/__tests__/ai-trigger-service.test.ts new file mode 100644 index 0000000000..194b9634a5 --- /dev/null +++ b/packages/business/__tests__/ai-trigger-service.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockInsert, + mockInsertValues, + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockDelete, + mockFindMany, + mockDispatchAuditRecord, + mockListPaginated, + mockCount, + mockFindByIdAndWorkspace, +} = vi.hoisted(() => { + const mockInsertReturning = vi.fn() + const mockInsertValues = vi.fn(() => ({ returning: mockInsertReturning })) + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + + const mockUpdateReturning = vi.fn() + const mockUpdateWhere = vi.fn(() => ({ returning: mockUpdateReturning })) + const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) + const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) + + const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) + + return { + mockInsert, + mockInsertValues, + mockInsertReturning, + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockUpdateReturning, + mockDelete, + mockDeleteWhere, + mockFindMany: vi.fn(), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + mockListPaginated: vi.fn(), + mockCount: vi.fn(), + mockFindByIdAndWorkspace: vi.fn(), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + insert: mockInsert, + update: mockUpdate, + delete: mockDelete, + query: { aiTriggerModel: { findMany: mockFindMany } }, + }, + and: (...args: unknown[]) => ({ __and: args }), + eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), + inArray: (a: unknown, b: unknown) => ({ __inArray: [a, b] }), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + aiTriggerRepository: { + listPaginated: mockListPaginated, + count: mockCount, + findByIdAndWorkspace: mockFindByIdAndWorkspace, + }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + aiTriggerModel: { id: "aiTrigger.id", workspaceId: "aiTrigger.workspaceId" }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { aiTriggerService } = await import("../src/ai-trigger/service") + +const WS = "ws-1" + +afterEach(() => { + vi.clearAllMocks() +}) + +describe("aiTriggerService.list", () => { + test("paginates via the repository and computes pageCount", async () => { + mockListPaginated.mockResolvedValueOnce([{ id: "ai-trigger-1" }]) + mockCount.mockResolvedValueOnce(3) + + const result = await aiTriggerService.list({ + workspaceId: WS, + page: 1, + perPage: 2, + }) + + expect(result).toEqual({ data: [{ id: "ai-trigger-1" }], pageCount: 2 }) + expect(mockListPaginated).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WS }), + ) + }) +}) + +describe("aiTriggerService.findOrFail", () => { + test("returns the row when found", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ id: "ai-trigger-1" }) + + const result = await aiTriggerService.findOrFail({ + workspaceId: WS, + id: "ai-trigger-1", + }) + + expect(result).toEqual({ id: "ai-trigger-1" }) + }) + + test("throws not found when no row matches", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce(undefined) + + await expect( + aiTriggerService.findOrFail({ workspaceId: WS, id: "missing" }), + ).rejects.toThrow("AITrigger not found") + }) +}) + +describe("aiTriggerService.create", () => { + test("inserts scoped to the workspace and audits", async () => { + const created = { id: "ai-trigger-1", name: "New trigger" } + mockInsertValues.mockReturnValueOnce({ + returning: vi.fn().mockResolvedValueOnce([created]), + }) + + const result = await aiTriggerService.create({ + workspaceId: WS, + data: { name: "New trigger" }, + }) + + expect(result).toEqual(created) + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WS, name: "New trigger" }), + ) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith( + expect.objectContaining({ action: "create" }), + ) + }) +}) + +describe("aiTriggerService.update", () => { + test("verifies existence, updates, and audits", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ + id: "ai-trigger-1", + name: "Old name", + }) + const updated = { id: "ai-trigger-1", name: "New name" } + mockUpdateWhere.mockReturnValueOnce({ + returning: vi.fn().mockResolvedValueOnce([updated]), + }) + + const result = await aiTriggerService.update( + { workspaceId: WS, id: "ai-trigger-1" }, + { name: "New name" }, + ) + + expect(result).toEqual(updated) + expect(mockUpdateSet).toHaveBeenCalledWith({ name: "New name" }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith( + expect.objectContaining({ action: "update" }), + ) + }) + + test("throws not found before updating when the row does not exist", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce(undefined) + + await expect( + aiTriggerService.update( + { workspaceId: WS, id: "missing" }, + { name: "x" }, + ), + ).rejects.toThrow("AITrigger not found") + + expect(mockUpdate).not.toHaveBeenCalled() + }) +}) + +describe("aiTriggerService.duplicate", () => { + test("copies the source row with a new id and _copy suffix", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ + id: "ai-trigger-1", + workspaceId: WS, + name: "Original", + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + description: "desc", + }) + const duplicated = { id: "generated-id", name: "Original _copy" } + mockInsertValues.mockReturnValueOnce({ + returning: vi.fn().mockResolvedValueOnce([duplicated]), + }) + + const result = await aiTriggerService.duplicate({ + workspaceId: WS, + id: "ai-trigger-1", + }) + + expect(result).toEqual(duplicated) + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + id: "generated-id", + name: "Original _copy", + workspaceId: WS, + description: "desc", + }), + ) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith( + expect.objectContaining({ action: "create" }), + ) + }) +}) + +describe("aiTriggerService.deleteMany", () => { + test("no-ops without auditing when nothing matches", async () => { + mockFindMany.mockResolvedValueOnce([]) + + await aiTriggerService.deleteMany({ workspaceId: WS, ids: ["missing"] }) + + expect(mockDelete).not.toHaveBeenCalled() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("deletes matched rows and audits", async () => { + mockFindMany.mockResolvedValueOnce([{ id: "ai-trigger-1" }]) + + await aiTriggerService.deleteMany({ + workspaceId: WS, + ids: ["ai-trigger-1"], + }) + + expect(mockDelete).toHaveBeenCalled() + expect(mockDispatchAuditRecord).toHaveBeenCalledWith( + expect.objectContaining({ action: "delete" }), + ) + }) +}) diff --git a/packages/business/__tests__/broadcast-service-update.test.ts b/packages/business/__tests__/broadcast-service-update.test.ts new file mode 100644 index 0000000000..856dab4883 --- /dev/null +++ b/packages/business/__tests__/broadcast-service-update.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test, vi } from "vitest" + +const { mockFindOrFail, mockUpdate, mockUpdateSet, mockDispatchAuditRecord } = + vi.hoisted(() => { + const mockUpdateWhere = vi.fn().mockResolvedValue(undefined) + const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) + + return { + mockFindOrFail: vi.fn(), + mockUpdate, + mockUpdateSet, + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } + }) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { update: mockUpdate }, + and: (...args: unknown[]) => ({ __and: args }), + asc: vi.fn(), + count: vi.fn(), + desc: vi.fn(), + eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), + findOrFail: mockFindOrFail, + gt: vi.fn(), + inArray: vi.fn(), + isNotNull: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + or: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn() }), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + broadcastStatuses: { enum: { draft: "draft", scheduled: "scheduled" } }, + findBroadcastChannelCapability: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + broadcastModel: { id: "broadcast.id" }, + contactInboxModel: {}, + contactModel: {}, + contactsOnBroadcastsModel: {}, + 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: vi.fn(), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/inbox/service", () => ({ inboxService: {} })) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { broadcastService } = await import("../src/broadcast/service") + +const WS = "ws-1" +const BROADCAST_ID = "bc-1" + +describe("broadcastService.update", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("renames the broadcast and audits", async () => { + mockFindOrFail.mockResolvedValue({ id: BROADCAST_ID, workspaceId: WS }) + + await broadcastService.update( + { workspaceId: WS, id: BROADCAST_ID }, + { name: "New Name" }, + ) + + expect(mockFindOrFail).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: BROADCAST_ID, + workspaceId: WS, + deletedAt: { isNull: true }, + }), + }), + ) + expect(mockUpdateSet).toHaveBeenCalledWith({ name: "New Name" }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `updated a broadcast (#${BROADCAST_ID})`, + }) + }) + + test("propagates the not-found error and never updates", async () => { + mockFindOrFail.mockRejectedValue(new Error("Not found")) + + await expect( + broadcastService.update( + { workspaceId: WS, id: BROADCAST_ID }, + { name: "New Name" }, + ), + ).rejects.toThrow("Not found") + + expect(mockUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/src/features/flows/actions/__tests__/filter-flow-action.test.ts b/packages/business/__tests__/flow-filters.test.ts similarity index 81% rename from apps/builder/src/features/flows/actions/__tests__/filter-flow-action.test.ts rename to packages/business/__tests__/flow-filters.test.ts index 319ca2fb27..81ec5a746e 100644 --- a/apps/builder/src/features/flows/actions/__tests__/filter-flow-action.test.ts +++ b/packages/business/__tests__/flow-filters.test.ts @@ -1,37 +1,24 @@ import { stepTypes } from "@chatbotx.io/flow-config" import { describe, expect, test } from "vitest" -import type { FlowWithVersionsResource } from "../../schema/resource" import { filterFlowsByStartStepType, filterFlowsByTemplateIds, hasStartNode, -} from "../filter-flow-action" +} from "../src/flow/filters" + +type TestFlow = { + id: string + flowVersions: Array<{ nodes: unknown }> +} const flowWithSteps = ( id: string, steps: Array<{ stepType?: string; template?: { id?: string } }>, options?: { isStartNode?: boolean }, -): FlowWithVersionsResource => ({ +): TestFlow => ({ id, - name: `Flow ${id}`, - active: true, - enableInInbox: true, - workspaceId: "workspace-1", - folderId: null, - currentVersionId: null, - draftVersionId: null, - createdAt: new Date("2026-01-01T00:00:00Z"), - updatedAt: new Date("2026-01-01T00:00:00Z"), flowVersions: [ { - id: `${id}-version`, - flowId: id, - workspaceId: "workspace-1", - startNodeId: "node-1", - createdAt: new Date("2026-01-01T00:00:00Z"), - isDraft: true, - isLatest: false, - edges: [], nodes: [ { id: "node-1", @@ -45,7 +32,7 @@ const flowWithSteps = ( ], }) -describe("flow action filters", () => { +describe("flow filters", () => { test("detects a step on the start node only", () => { expect( hasStartNode( @@ -129,16 +116,10 @@ describe("flow action filters", () => { template: { id: "template-2" }, }, ]) - const wrongStartTemplateFlow = { - ...flowWithSteps("wrong-start-template", [ - { - stepType: stepTypes.enum.sendWaTemplateMessage, - template: { id: "template-2" }, - }, - ]), + const wrongStartTemplateFlow: TestFlow = { + id: "wrong-start-template", flowVersions: [ { - ...flowWithSteps("wrong-start-template", []).flowVersions[0], nodes: [ { id: "start-node", @@ -171,7 +152,7 @@ describe("flow action filters", () => { ], }, ], - } satisfies FlowWithVersionsResource + } expect( filterFlowsByTemplateIds( diff --git a/packages/business/__tests__/flow-import-flow-export.test.ts b/packages/business/__tests__/flow-import-flow-export.test.ts index 5174e390fc..440174c899 100644 --- a/packages/business/__tests__/flow-import-flow-export.test.ts +++ b/packages/business/__tests__/flow-import-flow-export.test.ts @@ -46,6 +46,7 @@ vi.mock("@chatbotx.io/database/partials", () => ({ vi.mock("@chatbotx.io/database/repositories", () => ({ flowRepository: { listIdsByIds: vi.fn() }, + whatsappMessageTemplateRepository: { listIdsByIntegration: vi.fn() }, })) vi.mock("@chatbotx.io/database/schema", () => ({ @@ -57,6 +58,10 @@ vi.mock("@chatbotx.io/database/schema", () => ({ vi.mock("@chatbotx.io/flow-config", () => ({ remapFlowGraphReferences: mockRemapFlowGraphReferences, sendMessageNodeDefaultFn: vi.fn(() => ({ id: "default-node" })), + // flowService.list's startType filtering imports stepTypes for the + // sendWaTemplateMessage branch — this suite never exercises `list`, so a + // minimal stub (rather than the real enum) keeps the mock self-contained. + stepTypes: { enum: { sendWaTemplateMessage: "sendWaTemplateMessage" } }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/__tests__/flow.service.test.ts b/packages/business/__tests__/flow.service.test.ts index 1e24e16736..20c14b8bb0 100644 --- a/packages/business/__tests__/flow.service.test.ts +++ b/packages/business/__tests__/flow.service.test.ts @@ -12,6 +12,10 @@ const { mockInsert, mockInsertReturning, mockInsertValues, + mockTopLevelFlowFindFirst, + mockUpdate, + mockUpdateSet, + mockUpdateReturning, } = vi.hoisted(() => { const mockInsertReturning = vi.fn().mockResolvedValue([{ id: "flow-1" }]) const mockInsertValues = vi.fn(() => @@ -21,6 +25,11 @@ const { ) const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + const mockUpdateReturning = vi.fn() + const mockUpdateWhere = vi.fn(() => ({ returning: mockUpdateReturning })) + const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) + const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) + return { mockAudit: vi.fn(), mockCreateId: vi.fn(), @@ -31,6 +40,11 @@ const { mockInsert, mockInsertReturning, mockInsertValues, + mockTopLevelFlowFindFirst: vi.fn(), + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockUpdateReturning, } }) @@ -51,7 +65,12 @@ vi.mock("@chatbotx.io/database/client", () => ({ db: { transaction: mockDbTransaction, insert: mockInsert, + update: mockUpdate, + query: { + flowModel: { findFirst: mockTopLevelFlowFindFirst }, + }, }, + eq: (...args: unknown[]) => ({ eq: args }), })) // The repositories barrel transitively pulls in the contact-filter query @@ -59,6 +78,7 @@ vi.mock("@chatbotx.io/database/client", () => ({ // uses `listIdsByIds` (covered elsewhere), so a stub keeps that chain out. vi.mock("@chatbotx.io/database/repositories", () => ({ flowRepository: { listIdsByIds: vi.fn(async () => []) }, + whatsappMessageTemplateRepository: { listIdsByIntegration: vi.fn() }, })) vi.mock("@chatbotx.io/database/partials", () => ({ @@ -87,6 +107,10 @@ vi.mock("@chatbotx.io/flow-config", () => ({ increase: "O04", decrease: "O05", }, + // flowService.list's startType filtering imports stepTypes for the + // sendWaTemplateMessage branch — this suite never exercises `list`, so a + // minimal stub (rather than the real enum) keeps the mock self-contained. + stepTypes: { enum: { sendWaTemplateMessage: "sendWaTemplateMessage" } }, })) vi.mock("../src/base.service", () => ({ @@ -265,6 +289,62 @@ describe("flowService.duplicate", () => { }) }) +describe("flowService.update", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("throws when the flow does not exist in the workspace", async () => { + mockTopLevelFlowFindFirst.mockResolvedValue(undefined) + + await expect( + flowService.update( + { workspaceId: "ws-1", id: "flow-1" }, + { name: "New Name" }, + ), + ).rejects.toThrow("Flow not found") + + expect(mockUpdate).not.toHaveBeenCalled() + }) + + test("no-ops when nothing changed", async () => { + mockTopLevelFlowFindFirst.mockResolvedValue({ + id: "flow-1", + workspaceId: "ws-1", + name: "Welcome", + active: true, + enableInInbox: true, + }) + + await flowService.update( + { workspaceId: "ws-1", id: "flow-1" }, + { name: "Welcome", active: true }, + ) + + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockAudit).not.toHaveBeenCalled() + }) + + test("updates and audits when a field changed", async () => { + mockTopLevelFlowFindFirst.mockResolvedValue({ + id: "flow-1", + workspaceId: "ws-1", + name: "Welcome", + active: true, + enableInInbox: true, + }) + mockUpdateReturning.mockResolvedValue([{ id: "flow-1" }]) + + await flowService.update( + { workspaceId: "ws-1", id: "flow-1" }, + { name: "Onboarding" }, + ) + + expect(mockUpdateSet).toHaveBeenCalledWith({ name: "Onboarding" }) + expect(mockAudit).toHaveBeenCalledWith("update", "updated a flow (#flow-1)") + }) +}) + describe("flowService.createPublishedDefault", () => { afterEach(() => { vi.clearAllMocks() diff --git a/packages/business/__tests__/reflink-service.test.ts b/packages/business/__tests__/reflink-service.test.ts new file mode 100644 index 0000000000..da386b5144 --- /dev/null +++ b/packages/business/__tests__/reflink-service.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, test, vi } from "vitest" + +const { + mockInsert, + mockInsertValues, + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockListPaginated, + mockCount, + mockFindByIdAndWorkspace, + mockIsUniqueViolationError, +} = vi.hoisted(() => { + const mockInsertValues = vi.fn() + const mockInsert = vi.fn(() => ({ values: mockInsertValues })) + + const mockUpdateWhere = vi.fn() + const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) + const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) + + return { + mockInsert, + mockInsertValues, + mockUpdate, + mockUpdateSet, + mockUpdateWhere, + mockListPaginated: vi.fn(), + mockCount: vi.fn(), + mockFindByIdAndWorkspace: vi.fn(), + mockIsUniqueViolationError: vi.fn(() => false), + } +}) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { insert: mockInsert, update: mockUpdate }, + and: (...args: unknown[]) => ({ __and: args }), + desc: vi.fn(), + eq: (a: unknown, b: unknown) => ({ __eq: [a, b] }), + inArray: (a: unknown, b: unknown) => ({ __inArray: [a, b] }), + isUniqueViolationError: mockIsUniqueViolationError, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + reflinkRepository: { + listPaginated: mockListPaginated, + count: mockCount, + findByIdAndWorkspace: mockFindByIdAndWorkspace, + }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + reflinkModel: { + id: "reflink.id", + workspaceId: "reflink.workspaceId", + type: "reflink.type", + name: "reflink.name", + createdAt: "reflink.createdAt", + }, +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), +})) + +vi.mock("../src/template/installed-resource.service", () => ({ + assertDeletable: vi.fn().mockResolvedValue(undefined), +})) + +const { reflinkService } = await import("../src/reflink/service") + +const WS = "ws-1" + +afterEach(() => { + vi.clearAllMocks() +}) + +describe("reflinkService.list", () => { + test("paginates via the repository and computes pageCount", async () => { + mockListPaginated.mockResolvedValueOnce([{ id: "reflink-1" }]) + mockCount.mockResolvedValueOnce(5) + + const result = await reflinkService.list({ + workspaceId: WS, + page: 1, + perPage: 2, + }) + + expect(result).toEqual({ data: [{ id: "reflink-1" }], pageCount: 3 }) + expect(mockListPaginated).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WS }), + ) + }) +}) + +describe("reflinkService.findOrFail", () => { + test("returns the row when found", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ id: "reflink-1" }) + + const result = await reflinkService.findOrFail({ + workspaceId: WS, + id: "reflink-1", + }) + + expect(result).toEqual({ id: "reflink-1" }) + }) + + test("throws not found when no row matches", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce(undefined) + + await expect( + reflinkService.findOrFail({ workspaceId: WS, id: "missing" }), + ).rejects.toThrow("Reflink not found") + }) +}) + +describe("reflinkService.create", () => { + test("inserts scoped to the workspace with type=refLink", async () => { + const created = { id: "reflink-1", name: "Summer promo" } + mockInsertValues.mockReturnValueOnce({ + returning: vi.fn().mockResolvedValueOnce([created]), + }) + + const result = await reflinkService.create({ + workspaceId: WS, + data: { name: "Summer promo", flowId: "flow-1" }, + }) + + expect(result).toEqual(created) + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WS, + type: "refLink", + name: "Summer promo", + flowId: "flow-1", + }), + ) + }) + + test("maps a unique-violation on name into a field-scoped validation error", async () => { + mockIsUniqueViolationError.mockReturnValueOnce(true) + mockInsertValues.mockReturnValueOnce({ + returning: vi.fn().mockRejectedValueOnce(new Error("duplicate key")), + }) + + await expect( + reflinkService.create({ + workspaceId: WS, + data: { name: "Summer promo", flowId: "flow-1" }, + }), + ).rejects.toThrow("Name is already taken") + }) +}) + +describe("reflinkService.update", () => { + test("verifies existence then updates", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ + id: "reflink-1", + name: "Old name", + }) + const updated = { id: "reflink-1", name: "New name" } + mockUpdateWhere.mockReturnValueOnce({ + returning: vi.fn().mockResolvedValueOnce([updated]), + }) + + const result = await reflinkService.update( + { workspaceId: WS, id: "reflink-1" }, + { name: "New name" }, + ) + + expect(result).toEqual(updated) + expect(mockUpdateSet).toHaveBeenCalledWith({ name: "New name" }) + }) + + test("throws not found before updating when the row does not exist", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce(undefined) + + await expect( + reflinkService.update({ workspaceId: WS, id: "missing" }, { name: "x" }), + ).rejects.toThrow("Reflink not found") + + expect(mockUpdate).not.toHaveBeenCalled() + }) + + test("maps a unique-violation on name into a field-scoped validation error", async () => { + mockFindByIdAndWorkspace.mockResolvedValueOnce({ + id: "reflink-1", + name: "Old name", + }) + mockIsUniqueViolationError.mockReturnValueOnce(true) + mockUpdateWhere.mockReturnValueOnce({ + returning: vi.fn().mockRejectedValueOnce(new Error("duplicate key")), + }) + + await expect( + reflinkService.update( + { workspaceId: WS, id: "reflink-1" }, + { name: "Taken name" }, + ), + ).rejects.toThrow("Name is already taken") + }) +}) diff --git a/packages/business/__tests__/sequence-service.test.ts b/packages/business/__tests__/sequence-service.test.ts index 4e23de9b9f..425fb498f6 100644 --- a/packages/business/__tests__/sequence-service.test.ts +++ b/packages/business/__tests__/sequence-service.test.ts @@ -12,6 +12,8 @@ const { mockDispatchAuditRecord, mockStepFindFirst, mockStepUpdate, + mockStepUpdateSet, + mockStepUpdateReturning, mockStepInsert, mockStepDelete, sequenceModelStub, @@ -52,6 +54,8 @@ const { mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), mockStepFindFirst: vi.fn(), mockStepUpdate, + mockStepUpdateSet, + mockStepUpdateReturning, mockStepInsert, mockStepDelete, sequenceModelStub: { @@ -146,6 +150,104 @@ describe("sequenceService.create", () => { }) }) +describe("sequenceService.update", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("no-ops when nothing changed", async () => { + mockFindOrFail.mockResolvedValue({ + id: "seq-1", + name: "Seq", + active: true, + }) + + await sequenceService.update( + { workspaceId: WS, id: "seq-1" }, + { active: true }, + ) + + expect(mockStepUpdate).not.toHaveBeenCalled() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("updates and audits with a generic detail for multi-field changes", async () => { + mockFindOrFail.mockResolvedValue({ + id: "seq-1", + name: "Seq", + active: false, + }) + mockStepUpdateReturning.mockResolvedValue([{ id: "seq-1" }]) + + await sequenceService.update( + { workspaceId: WS, id: "seq-1" }, + { name: "New Name", active: true }, + ) + + expect(mockStepUpdateSet).toHaveBeenCalledWith({ + name: "New Name", + active: true, + }) + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: "updated a sequence (#seq-1)", + }) + }) + + test("audits an 'enabled' detail when only active flips true", async () => { + mockFindOrFail.mockResolvedValue({ + id: "seq-1", + name: "Seq", + active: false, + }) + mockStepUpdateReturning.mockResolvedValue([{ id: "seq-1" }]) + + await sequenceService.update( + { workspaceId: WS, id: "seq-1" }, + { active: true }, + ) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: "enabled a sequence (#seq-1)", + }) + }) + + test("throws validationException on the name field for a 23505 unique violation", async () => { + mockFindOrFail.mockResolvedValue({ + id: "seq-1", + name: "Seq", + active: false, + }) + const dbError = Object.assign(new Error("unique violation"), { + cause: { code: "23505" }, + }) + mockStepUpdateReturning.mockRejectedValueOnce(dbError) + mockIsDatabaseError.mockReturnValueOnce(true) + + await expect( + sequenceService.update( + { workspaceId: WS, id: "seq-1" }, + { name: "Duplicate" }, + ), + ).rejects.toMatchObject({ + code: "validation", + field: "name", + message: "Name is already taken.", + }) + }) + + test("propagates the not-found error and never updates", async () => { + mockFindOrFail.mockRejectedValue(new Error("Sequence not found")) + + await expect( + sequenceService.update({ workspaceId: WS, id: "missing" }, { name: "X" }), + ).rejects.toThrow("Sequence not found") + + expect(mockStepUpdate).not.toHaveBeenCalled() + }) +}) + describe("sequenceService.delete", () => { afterEach(() => { vi.clearAllMocks() diff --git a/packages/business/src/ai-trigger/index.ts b/packages/business/src/ai-trigger/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/ai-trigger/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/ai-trigger/service.ts b/packages/business/src/ai-trigger/service.ts new file mode 100644 index 0000000000..3cbd52cd96 --- /dev/null +++ b/packages/business/src/ai-trigger/service.ts @@ -0,0 +1,131 @@ +import { and, db, eq, inArray } from "@chatbotx.io/database/client" +import { aiTriggerRepository } from "@chatbotx.io/database/repositories" +import { aiTriggerModel } from "@chatbotx.io/database/schema" +import type { AITriggerModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" +import { BaseService } from "../base.service" +import { notFoundException } from "../errors" + +type AITriggerWriteData = { + name?: string + description?: string | null + questions?: unknown[] + flowId?: string | null + finalMessage?: string | null +} + +class AITriggerService extends BaseService { + async list(input: { + workspaceId: string + name?: string | null + page: number + perPage: number + sort?: { id: string; desc: boolean }[] | null + }): Promise<{ data: AITriggerModel[]; pageCount: number }> { + const [data, total] = await Promise.all([ + aiTriggerRepository.listPaginated(input), + aiTriggerRepository.count(input), + ]) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } + + async findOrFail(input: { + workspaceId: string + id: string + }): Promise { + const aiTrigger = await aiTriggerRepository.findByIdAndWorkspace(input) + if (!aiTrigger) { + throw notFoundException("AITrigger not found") + } + return aiTrigger + } + + async create(input: { + workspaceId: string + data: AITriggerWriteData & { name: string } + }): Promise { + const [created] = await db + .insert(aiTriggerModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + ...input.data, + }) + .returning() + + await this.audit("create", `created a new AI trigger (#${created.id})`) + + return created + } + + async update( + ctx: { workspaceId: string; id: string }, + data: AITriggerWriteData, + ): Promise { + const aiTrigger = await this.findOrFail(ctx) + + const [updated] = await db + .update(aiTriggerModel) + .set(data) + .where(eq(aiTriggerModel.id, aiTrigger.id)) + .returning() + + await this.audit("update", `updated an AI trigger (#${aiTrigger.id})`) + + return updated + } + + async duplicate(input: { + workspaceId: string + id: string + }): Promise { + const source = await this.findOrFail(input) + + const { id: _id, name, createdAt, updatedAt, ...rest } = source + + const [created] = await db + .insert(aiTriggerModel) + .values({ + ...rest, + id: createId(), + name: `${name} _copy`, + }) + .returning() + + await this.audit("create", `duplicated an AI trigger (#${source.id})`) + + return created + } + + async deleteMany(input: { + workspaceId: string + ids: string[] + }): Promise { + const deleted = await db.query.aiTriggerModel.findMany({ + where: { workspaceId: input.workspaceId, id: { in: input.ids } }, + columns: { id: true }, + }) + if (deleted.length === 0) { + return + } + + await db + .delete(aiTriggerModel) + .where( + and( + eq(aiTriggerModel.workspaceId, input.workspaceId), + inArray(aiTriggerModel.id, input.ids), + ), + ) + + await this.audit( + "delete", + `deleted AI trigger${deleted.length > 1 ? "s" : ""} (${deleted.map((row) => `#${row.id}`).join(", ")})`, + ) + } +} + +export const aiTriggerService = new AITriggerService() diff --git a/packages/business/src/bot-field/service.ts b/packages/business/src/bot-field/service.ts index dcd6670e2b..df9b7bb7af 100644 --- a/packages/business/src/bot-field/service.ts +++ b/packages/business/src/bot-field/service.ts @@ -4,6 +4,7 @@ import { db, eq, inArray, + isDatabaseError, relationsFilterToSQL, type SQL, sql, @@ -36,7 +37,11 @@ import { normalizeCustomFieldValueForStorage, type SourceTimezoneResolver, } from "../contact-custom-field/normalize" -import { ChatbotXException, notFoundException } from "../errors" +import { + ChatbotXException, + notFoundException, + validationException, +} from "../errors" import { folderService } from "../folder/service" import { assertDeletable } from "../template/installed-resource.service" import type { PaginatedResult } from "../types" @@ -61,6 +66,7 @@ type CreateBotFieldData = { type UpdateBotFieldData = Partial const REGEX_BOT_FIELD_ID = /^\d+$/ +const UNIQUE_VIOLATION_CODE = "23505" /** * Which `CustomFieldType`s each `FieldOperationType` is valid against. @@ -459,11 +465,22 @@ class BotFieldService extends BaseService { }) } - const [updated] = await tx - .update(botFieldModel) - .set(data) - .where(eq(botFieldModel.id, existing.id)) - .returning() + let updated: BotFieldModel + try { + ;[updated] = await tx + .update(botFieldModel) + .set(data) + .where(eq(botFieldModel.id, existing.id)) + .returning() + } catch (error) { + if ( + isDatabaseError(error) && + error.cause.code === UNIQUE_VIOLATION_CODE + ) { + throw validationException("name", "Name is already taken") + } + throw error + } await this.invalidate({ workspaceId, ids: [existing.id] }) @@ -491,10 +508,21 @@ class BotFieldService extends BaseService { data, ) - const [botField] = await tx - .insert(botFieldModel) - .values({ id: createId(), workspaceId, ...preparedData }) - .returning() + let botField: BotFieldModel + try { + ;[botField] = await tx + .insert(botFieldModel) + .values({ id: createId(), workspaceId, ...preparedData }) + .returning() + } catch (error) { + if ( + isDatabaseError(error) && + error.cause.code === UNIQUE_VIOLATION_CODE + ) { + throw validationException("name", "Name is already taken") + } + throw error + } await this.invalidate({ workspaceId }) return botField diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index 9021af8b36..1ba1b20c88 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -543,6 +543,28 @@ class BroadcastService extends BaseService { return row ?? null } + /** Renames a broadcast. Distinct from `updateDraft`, which re-applies a full create payload. */ + async update( + ctx: { workspaceId: string; id: string }, + data: { name: string }, + ): Promise { + const broadcast = await findOrFail({ + table: broadcastModel, + where: { + id: ctx.id, + workspaceId: ctx.workspaceId, + deletedAt: { isNull: true }, + }, + }) + + await db + .update(broadcastModel) + .set(data) + .where(eq(broadcastModel.id, broadcast.id)) + + await this.audit("update", `updated a broadcast (#${broadcast.id})`) + } + /** * Re-applies a validated create payload to an existing draft. `saveAsDraft` * decides whether the row stays a draft or becomes `scheduled`, mirroring diff --git a/packages/business/src/flow-version/service.ts b/packages/business/src/flow-version/service.ts index b4b96b2e82..df80ab5d56 100644 --- a/packages/business/src/flow-version/service.ts +++ b/packages/business/src/flow-version/service.ts @@ -283,6 +283,39 @@ class FlowVersionService extends BaseService { ) } + /** + * Updates a draft flow version's nodes/edges in place — used for autosave + * while editing, distinct from `publish` which cuts a new immutable + * version. Mirrors `publish`'s not-found handling: a missing/non-draft + * version throws rather than silently no-op'ing. + */ + async updateDraft(input: { + workspaceId: string + id: string + nodes: FlowVersionModel["nodes"] + edges: FlowVersionModel["edges"] + }): Promise { + const flowVersion = await db.query.flowVersionModel.findFirst({ + where: { + id: input.id, + workspaceId: input.workspaceId, + isDraft: true, + }, + }) + + if (!flowVersion) { + throw notFoundException("Draft flow version not found") + } + + await db + .update(flowVersionModel) + .set({ + nodes: input.nodes, + edges: input.edges, + }) + .where(eq(flowVersionModel.id, flowVersion.id)) + } + async invalidateList(flowId: string): Promise { await this.invalidateCacheTags(`flows:${flowId}:versions`) } diff --git a/apps/builder/src/features/flows/actions/filter-flow-action.ts b/packages/business/src/flow/filters.ts similarity index 93% rename from apps/builder/src/features/flows/actions/filter-flow-action.ts rename to packages/business/src/flow/filters.ts index 61afffe644..f8d96a5a2e 100644 --- a/apps/builder/src/features/flows/actions/filter-flow-action.ts +++ b/packages/business/src/flow/filters.ts @@ -1,5 +1,4 @@ import { stepTypes } from "@chatbotx.io/flow-config" -import type { FlowNode } from "../schema/flow-node" const LEGACY_STEP_TYPE_ALIASES: Record = { [stepTypes.enum.sendWaTemplateMessage]: ["WA_TM01"], @@ -15,11 +14,18 @@ type StepWithTemplate = { template?: { id?: string } } +type FlowNodeLike = { + data?: { + isStartNode?: boolean + details?: { steps?: StepWithTemplate[] } + } +} + type FlowWithNodeVersions = { flowVersions: Array<{ nodes: unknown }> } -const isFlowNode = (node: unknown): node is FlowNode => +const isFlowNode = (node: unknown): node is FlowNodeLike => typeof node === "object" && node !== null export function hasStartNode(nodes: unknown, stepType: string): boolean { diff --git a/packages/business/src/flow/index.ts b/packages/business/src/flow/index.ts index 9376fea807..4495bfdeea 100644 --- a/packages/business/src/flow/index.ts +++ b/packages/business/src/flow/index.ts @@ -1 +1,2 @@ +export * from "./filters" export * from "./service" diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index eefc77c9cf..c44d5c1b03 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -1,24 +1,33 @@ -import { type DatabaseClient, db, inArray } from "@chatbotx.io/database/client" +import { + type DatabaseClient, + db, + eq, + inArray, +} from "@chatbotx.io/database/client" import { type CustomFieldType, rootFolderId, } from "@chatbotx.io/database/partials" -import { flowRepository } from "@chatbotx.io/database/repositories" +import { + type FlowListInput, + flowRepository, + whatsappMessageTemplateRepository, +} from "@chatbotx.io/database/repositories" import { flowAnalyticsSessionModel, flowModel, flowVersionModel, } from "@chatbotx.io/database/schema" import type { FlowModel, FlowVersionModel } from "@chatbotx.io/database/types" -import type { - EdgeSchema, - FlowExportBotField, - FlowExportCustomField, - FlowVersionSchema, -} from "@chatbotx.io/flow-config" +import { parsePagination } from "@chatbotx.io/database/utils" import { + type EdgeSchema, + type FlowExportBotField, + type FlowExportCustomField, + type FlowVersionSchema, remapFlowGraphReferences, sendMessageNodeDefaultFn, + stepTypes, } from "@chatbotx.io/flow-config" import { createId } from "@chatbotx.io/utils" import { customFieldResolutionKey } from "@chatbotx.io/utils/custom-field" @@ -29,6 +38,7 @@ import { notFoundException } from "../errors" import { flowVersionService } from "../flow-version" import { folderService } from "../folder/service" import { assertDeletable } from "../template/installed-resource.service" +import { filterFlowsByStartStepType, filterFlowsByTemplateIds } from "./filters" type FieldManifestEntry = { name: string; type: CustomFieldType } @@ -80,6 +90,73 @@ class FlowService extends BaseService { }) } + /** + * Paginated flow list with draft/latest versions attached. When + * `startType` is given, the DB-level page is re-filtered in memory by the + * first start node's step type (and, for WhatsApp template steps, by + * `integrationWhatsappId`'s bound template ids) — mirrors the pre-move + * `listFlows` query adapter, including recomputing `total`/`pageCount` + * off the filtered set rather than the DB count. + */ + async list( + input: FlowListInput & { + page?: number | null + perPage?: number | null + startType?: string | null + integrationWhatsappId?: string | null + }, + ): Promise<{ + data: Awaited> + pageCount: number + page?: number + perPage?: number + }> { + const pagination = parsePagination(input) + + let [data, total] = await Promise.all([ + flowRepository.listWithVersions(input), + flowRepository.count(input), + ]) + + if (input.startType) { + data = filterFlowsByStartStepType(data, input.startType) + + if (input.startType === stepTypes.enum.sendWaTemplateMessage) { + if (input.integrationWhatsappId) { + const templateIds = + await whatsappMessageTemplateRepository.listIdsByIntegration({ + integrationWhatsappId: input.integrationWhatsappId, + }) + data = filterFlowsByTemplateIds(data, templateIds) + } else { + data = [] + } + } + + total = data.length + } + + const pageCount = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + return { data, pageCount, ...pagination } + } + + /** Unguarded flow detail with all versions — callers enforce access. */ + async findById(input: { + workspaceId: string + id: string + }): Promise< + NonNullable>> + > { + const flow = await flowRepository.findWithVersions(input) + if (!flow) { + throw notFoundException("Flow does not exists.") + } + return flow + } + async exists( workspaceId: string, flowId: string, @@ -271,6 +348,40 @@ class FlowService extends BaseService { return { id: flow.id } } + /** + * Partial update of a flow's name/active/enableInInbox. No-ops (and skips + * the audit record) when every field matches the current row, mirroring + * the guard the old `update-flow-action.ts` implementation had. + */ + async update( + ctx: { workspaceId: string; id: string }, + data: { name?: string; active?: boolean; enableInInbox?: boolean }, + ): Promise { + const flow = await this.findBy(ctx) + if (!flow) { + throw notFoundException("Flow not found") + } + + const hasChanges = Object.entries(data).some( + ([key, value]) => flow[key as keyof typeof data] !== value, + ) + if (!hasChanges) { + return + } + + const updated = await db + .update(flowModel) + .set(data) + .where(eq(flowModel.id, flow.id)) + .returning({ id: flowModel.id }) + + if (updated.length === 0) { + return + } + + await this.audit("update", `updated a flow (#${flow.id})`) + } + duplicate(input: { workspaceId: string; id: string }): Promise { return db.transaction(async (tx) => { const flow = await this.findBy(input, tx) diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index 8365f6c623..e81e49b9a3 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -2,6 +2,7 @@ export * from "./ads-conversion" export * from "./ai-agent" export * from "./ai-function" export * from "./ai-mcp-server" +export * from "./ai-trigger" export * from "./appointment" export * from "./appointment-calendar" export * from "./appointment-external-calendar" diff --git a/packages/business/src/reflink/service.ts b/packages/business/src/reflink/service.ts index f8f83c9421..d44155f49e 100644 --- a/packages/business/src/reflink/service.ts +++ b/packages/business/src/reflink/service.ts @@ -1,12 +1,106 @@ -import { and, db, desc, eq, inArray } from "@chatbotx.io/database/client" +import { + and, + db, + desc, + eq, + inArray, + isUniqueViolationError, +} from "@chatbotx.io/database/client" +import { reflinkRepository } from "@chatbotx.io/database/repositories" import { reflinkModel } from "@chatbotx.io/database/schema" +import type { ReflinkModel } from "@chatbotx.io/database/types" +import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" +import { notFoundException, validationException } from "../errors" import { assertDeletable } from "../template/installed-resource.service" type SelectOptionRow = { id: string; name: string } const OPTION_LIST_LIMIT = 500 +type ReflinkCreateData = { + name: string + flowId: string + customFieldId?: string | null +} + +type ReflinkUpdateData = Partial + class ReflinkService extends BaseService { + async list(input: { + workspaceId: string + keyword?: string | null + page: number + perPage: number + sort?: { id: string; desc: boolean }[] | null + }): Promise<{ + data: Awaited> + pageCount: number + }> { + const [data, totalRows] = await Promise.all([ + reflinkRepository.listPaginated(input), + reflinkRepository.count(input), + ]) + + const pageCount = Math.ceil(totalRows / input.perPage) + + return { data, pageCount } + } + + async findOrFail(input: { + workspaceId: string + id: string + }): Promise { + const reflink = await reflinkRepository.findByIdAndWorkspace(input) + if (!reflink) { + throw notFoundException("Reflink not found") + } + return reflink + } + + async create(input: { + workspaceId: string + data: ReflinkCreateData + }): Promise { + try { + const [created] = await db + .insert(reflinkModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + type: "refLink", + ...input.data, + }) + .returning() + return created + } catch (error) { + if (isUniqueViolationError(error)) { + throw validationException("name", "Name is already taken") + } + throw error + } + } + + async update( + ctx: { workspaceId: string; id: string }, + data: ReflinkUpdateData, + ): Promise { + const reflink = await this.findOrFail(ctx) + + try { + const [updated] = await db + .update(reflinkModel) + .set(data) + .where(and(eq(reflinkModel.id, reflink.id))) + .returning() + return updated + } catch (error) { + if (isUniqueViolationError(error)) { + throw validationException("name", "Name is already taken") + } + throw error + } + } + async listOptions(input: { workspaceId: string }): Promise { diff --git a/packages/business/src/saved-reply/service.ts b/packages/business/src/saved-reply/service.ts index b55a2282a0..f0065f3d59 100644 --- a/packages/business/src/saved-reply/service.ts +++ b/packages/business/src/saved-reply/service.ts @@ -1,10 +1,51 @@ import { db, eq, findOrFail } from "@chatbotx.io/database/client" import { savedReplyModel } from "@chatbotx.io/database/schema" +import { createId } from "@chatbotx.io/utils" import { assertDeletable } from "../template/installed-resource.service" type SavedReplyModel = typeof savedReplyModel.$inferSelect class SavedReplyService { + async create(input: { + workspaceId: string + shortcut: string + text: string + }): Promise { + const [savedReply] = await db + .insert(savedReplyModel) + .values({ + id: createId(), + workspaceId: input.workspaceId, + shortcut: input.shortcut, + text: input.text, + }) + .returning() + + return savedReply + } + + async update( + ctx: { workspaceId: string; id: string }, + data: { shortcut: string; text: string }, + ): Promise { + const savedReply = await findOrFail({ + table: savedReplyModel, + where: { + id: ctx.id, + workspaceId: ctx.workspaceId, + }, + message: "Saved reply not found", + }) + + const [updatedSavedReply] = await db + .update(savedReplyModel) + .set(data) + .where(eq(savedReplyModel.id, savedReply.id)) + .returning() + + return updatedSavedReply + } + async delete(input: { workspaceId: string; id: string }): Promise { const savedReply = await findOrFail({ table: savedReplyModel, diff --git a/packages/business/src/sequence/service.ts b/packages/business/src/sequence/service.ts index 7c63a935d6..b882ea3be4 100644 --- a/packages/business/src/sequence/service.ts +++ b/packages/business/src/sequence/service.ts @@ -51,6 +51,64 @@ class SequenceService extends BaseService { return { sequenceId } } + /** + * Partial update of a sequence's name/active/folderId. No-ops when + * nothing changed. A duplicate `name` raises `validationException("name", + * ...)` — the action maps that to a form-level `returnValidationErrors` + * response, mirroring `create`'s handling of the same unique constraint. + */ + async update( + ctx: { workspaceId: string; id: string }, + data: { name?: string; active?: boolean; folderId?: string | null }, + ): Promise { + const sequence = await findOrFail({ + table: sequenceModel, + where: { + id: ctx.id, + workspaceId: ctx.workspaceId, + }, + message: "Sequence not found", + }) + + const changedEntries = Object.entries(data).filter( + ([key, value]) => sequence[key as keyof typeof data] !== value, + ) + + if (changedEntries.length === 0) { + return + } + + try { + const updated = await db + .update(sequenceModel) + .set(data) + .where(and(eq(sequenceModel.id, ctx.id))) + .returning({ id: sequenceModel.id }) + + if (updated.length === 0) { + return + } + } catch (error) { + if ( + isDatabaseError(error) && + error.cause.code === UNIQUE_VIOLATION_CODE + ) { + throw validationException("name", "Name is already taken.") + } + throw error + } + + const changedKeys = changedEntries.map(([key]) => key) + let detail = `updated a sequence (#${sequence.id})` + if (changedKeys.length === 1 && changedKeys[0] === "active") { + detail = data.active + ? `enabled a sequence (#${sequence.id})` + : `disabled a sequence (#${sequence.id})` + } + + await this.audit("update", detail) + } + async delete(input: { workspaceId: string; id: string }): Promise { const sequence = await findOrFail({ table: sequenceModel, diff --git a/packages/database/__tests__/ai-trigger-repository.test.ts b/packages/database/__tests__/ai-trigger-repository.test.ts new file mode 100644 index 0000000000..6bc216c3ae --- /dev/null +++ b/packages/database/__tests__/ai-trigger-repository.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + $count: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + aiTriggerModel: { + findMany: mocks.findMany, + findFirst: mocks.findFirst, + }, + }, + $count: mocks.$count, + }, + relationsFilterToSQL: vi.fn((_model: unknown, where: unknown) => where), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + aiTriggerModel: { id: "id-col", workspaceId: "workspaceId-col" }, +})) + +const { aiTriggerRepository } = await import( + "../src/repositories/ai-trigger/repository" +) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("aiTriggerRepository.listPaginated", () => { + test("scopes the query by workspaceId with pagination defaults", async () => { + const rows = [{ id: "ai-trigger-1" }] + mocks.findMany.mockResolvedValueOnce(rows) + + const result = await aiTriggerRepository.listPaginated({ + workspaceId: "ws-1", + page: 1, + perPage: 50, + }) + + expect(result).toEqual(rows) + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ workspaceId: "ws-1" }), + }), + ) + }) + + test("filters by name using a contains-style ilike when given", async () => { + mocks.findMany.mockResolvedValueOnce([]) + + await aiTriggerRepository.listPaginated({ + workspaceId: "ws-1", + name: "support", + page: 1, + perPage: 50, + }) + + const where = mocks.findMany.mock.calls[0]?.[0]?.where + expect(where.name).toEqual({ ilike: "%support%" }) + }) +}) + +describe("aiTriggerRepository.count", () => { + test("counts rows scoped to the workspace", async () => { + mocks.$count.mockResolvedValueOnce(3) + + const result = await aiTriggerRepository.count({ workspaceId: "ws-1" }) + + expect(result).toBe(3) + expect(mocks.$count).toHaveBeenCalledWith( + { id: "id-col", workspaceId: "workspaceId-col" }, + expect.objectContaining({ workspaceId: "ws-1" }), + ) + }) +}) + +describe("aiTriggerRepository.findByIdAndWorkspace", () => { + test("scopes the lookup by both id and workspaceId", async () => { + mocks.findFirst.mockResolvedValueOnce({ id: "ai-trigger-1" }) + + const result = await aiTriggerRepository.findByIdAndWorkspace({ + id: "ai-trigger-1", + workspaceId: "ws-1", + }) + + expect(result).toEqual({ id: "ai-trigger-1" }) + expect(mocks.findFirst).toHaveBeenCalledWith({ + where: { id: "ai-trigger-1", workspaceId: "ws-1" }, + }) + }) + + test("returns undefined when no row matches", async () => { + mocks.findFirst.mockResolvedValueOnce(undefined) + + const result = await aiTriggerRepository.findByIdAndWorkspace({ + id: "missing", + workspaceId: "ws-1", + }) + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/database/__tests__/reflink-repository.test.ts b/packages/database/__tests__/reflink-repository.test.ts new file mode 100644 index 0000000000..696a554d9a --- /dev/null +++ b/packages/database/__tests__/reflink-repository.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + $count: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + reflinkModel: { + findMany: mocks.findMany, + findFirst: mocks.findFirst, + }, + }, + $count: mocks.$count, + }, + relationsFilterToSQL: vi.fn((_model: unknown, where: unknown) => where), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + reflinkModel: { id: "id-col", workspaceId: "workspaceId-col" }, +})) + +const { reflinkRepository } = await import( + "../src/repositories/reflink/repository" +) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("reflinkRepository.listPaginated", () => { + test("scopes the query by workspaceId and forces type=refLink", async () => { + const rows = [{ id: "reflink-1" }] + mocks.findMany.mockResolvedValueOnce(rows) + + const result = await reflinkRepository.listPaginated({ + workspaceId: "ws-1", + page: 1, + perPage: 50, + }) + + expect(result).toEqual(rows) + const where = mocks.findMany.mock.calls[0]?.[0]?.where + expect(where).toEqual( + expect.objectContaining({ workspaceId: "ws-1", type: "refLink" }), + ) + }) + + test("includes flow and customField relations", async () => { + mocks.findMany.mockResolvedValueOnce([]) + + await reflinkRepository.listPaginated({ + workspaceId: "ws-1", + page: 1, + perPage: 50, + }) + + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ with: { flow: true, customField: true } }), + ) + }) + + test("filters by keyword using a contains-style ilike on name when given", async () => { + mocks.findMany.mockResolvedValueOnce([]) + + await reflinkRepository.listPaginated({ + workspaceId: "ws-1", + keyword: "promo", + page: 1, + perPage: 50, + }) + + const where = mocks.findMany.mock.calls[0]?.[0]?.where + expect(where.name).toEqual({ ilike: "%promo%" }) + }) +}) + +describe("reflinkRepository.count", () => { + test("counts rows scoped to the workspace and type=refLink", async () => { + mocks.$count.mockResolvedValueOnce(2) + + const result = await reflinkRepository.count({ workspaceId: "ws-1" }) + + expect(result).toBe(2) + expect(mocks.$count).toHaveBeenCalledWith( + { id: "id-col", workspaceId: "workspaceId-col" }, + expect.objectContaining({ workspaceId: "ws-1", type: "refLink" }), + ) + }) +}) + +describe("reflinkRepository.findByIdAndWorkspace", () => { + test("scopes the lookup by id, workspaceId, and type=refLink", async () => { + mocks.findFirst.mockResolvedValueOnce({ id: "reflink-1" }) + + const result = await reflinkRepository.findByIdAndWorkspace({ + id: "reflink-1", + workspaceId: "ws-1", + }) + + expect(result).toEqual({ id: "reflink-1" }) + expect(mocks.findFirst).toHaveBeenCalledWith({ + where: { id: "reflink-1", workspaceId: "ws-1", type: "refLink" }, + }) + }) + + test("returns undefined when no row matches", async () => { + mocks.findFirst.mockResolvedValueOnce(undefined) + + const result = await reflinkRepository.findByIdAndWorkspace({ + id: "missing", + workspaceId: "ws-1", + }) + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/database/src/repositories/ai-trigger/index.ts b/packages/database/src/repositories/ai-trigger/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/ai-trigger/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/ai-trigger/repository.ts b/packages/database/src/repositories/ai-trigger/repository.ts new file mode 100644 index 0000000000..f150b55a7a --- /dev/null +++ b/packages/database/src/repositories/ai-trigger/repository.ts @@ -0,0 +1,54 @@ +import { type DatabaseClient, db, relationsFilterToSQL } from "../../client" +import { aiTriggerModel } from "../../schema" +import { + getPaginationWithDefaults, + likeContains, + parseOrderByAsObject, +} from "../../utils" + +export type AITriggerListInput = { + workspaceId: string + name?: string | null + page: number + perPage: number + sort?: { id: string; desc: boolean }[] | null +} + +const buildWhere = (input: { workspaceId: string; name?: string | null }) => ({ + workspaceId: input.workspaceId, + name: input.name ? { ilike: likeContains(input.name) } : undefined, +}) + +export const aiTriggerRepository = { + async listPaginated(input: AITriggerListInput, tx: DatabaseClient = db) { + const where = buildWhere(input) + const pagination = getPaginationWithDefaults(input) + const orderBy = parseOrderByAsObject(aiTriggerModel, input) + + return await tx.query.aiTriggerModel.findMany({ + where, + orderBy, + ...pagination, + }) + }, + + async count( + input: { workspaceId: string; name?: string | null }, + tx: DatabaseClient = db, + ): Promise { + const where = buildWhere(input) + return await tx.$count( + aiTriggerModel, + relationsFilterToSQL(aiTriggerModel, where), + ) + }, + + async findByIdAndWorkspace( + input: { workspaceId: string; id: string }, + tx: DatabaseClient = db, + ) { + return await tx.query.aiTriggerModel.findFirst({ + where: { id: input.id, workspaceId: input.workspaceId }, + }) + }, +} diff --git a/packages/database/src/repositories/index.ts b/packages/database/src/repositories/index.ts index 104f4d023d..2d709e8462 100644 --- a/packages/database/src/repositories/index.ts +++ b/packages/database/src/repositories/index.ts @@ -3,6 +3,7 @@ export * from "./ads-conversion-rule" export * from "./ai-conversation-embedding" export * from "./ai-conversation-source" export * from "./ai-file-embedding" +export * from "./ai-trigger" export * from "./ai-workspace-scope" export * from "./appointment" export * from "./appointment-calendar" @@ -37,6 +38,7 @@ export * from "./meta-capi-event" export * from "./meta-catalog-item" export * from "./product" export * from "./product-category" +export * from "./reflink" export * from "./sequence" export * from "./template-selectable-resource" export * from "./trigger" diff --git a/packages/database/src/repositories/reflink/index.ts b/packages/database/src/repositories/reflink/index.ts new file mode 100644 index 0000000000..b1d08c5baf --- /dev/null +++ b/packages/database/src/repositories/reflink/index.ts @@ -0,0 +1 @@ +export * from "./repository" diff --git a/packages/database/src/repositories/reflink/repository.ts b/packages/database/src/repositories/reflink/repository.ts new file mode 100644 index 0000000000..36d98b9587 --- /dev/null +++ b/packages/database/src/repositories/reflink/repository.ts @@ -0,0 +1,62 @@ +import { type DatabaseClient, db, relationsFilterToSQL } from "../../client" +import { reflinkModel } from "../../schema" +import { + getPaginationWithDefaults, + likeContains, + parseOrderByAsObject, +} from "../../utils" + +export type ReflinkListInput = { + workspaceId: string + keyword?: string | null + page: number + perPage: number + sort?: { id: string; desc: boolean }[] | null +} + +const buildWhere = (input: { + workspaceId: string + keyword?: string | null +}) => ({ + workspaceId: input.workspaceId, + type: "refLink" as const, + ...(input.keyword ? { name: { ilike: likeContains(input.keyword) } } : {}), +}) + +export const reflinkRepository = { + async listPaginated(input: ReflinkListInput, tx: DatabaseClient = db) { + const where = buildWhere(input) + const pagination = getPaginationWithDefaults(input) + const orderBy = parseOrderByAsObject(reflinkModel, input) + + return await tx.query.reflinkModel.findMany({ + where, + orderBy, + ...pagination, + with: { + flow: true, + customField: true, + }, + }) + }, + + async count( + input: { workspaceId: string; keyword?: string | null }, + tx: DatabaseClient = db, + ): Promise { + const where = buildWhere(input) + return await tx.$count( + reflinkModel, + relationsFilterToSQL(reflinkModel, where), + ) + }, + + async findByIdAndWorkspace( + input: { workspaceId: string; id: string }, + tx: DatabaseClient = db, + ) { + return await tx.query.reflinkModel.findFirst({ + where: { id: input.id, workspaceId: input.workspaceId, type: "refLink" }, + }) + }, +} From aa3f9e4052dcc1e4e626c45ecdf91977e085a7db Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 15:25:56 +0700 Subject: [PATCH 3/8] fix(automation): close data-loss and correctness bugs in PR #1102's public API Remediates review findings from PR #1102's data-access refactor and public API widening: - PUT /v1/keywords/{id} without `keywords` silently wiped the automation's keywords to `[]`; the service now leaves the column untouched when the caller omits it, and moves the text/flowId mutual-exclusion + cross- workspace flowId validation down from the action into the service so every caller gets the same invariants. - POST /v1/ai-agents could return the wrong resource on a duplicate name (no unique constraint); `create` now returns the inserted id and the handler re-fetches by id instead of by name. - public-spec-operations.test.ts had no explicit `beforeAll` timeout, causing CI-only flakiness as the OpenAPI snapshot grew. - GET /v1/triggers loaded every trigger in the workspace then re-queried each one individually; added `triggerService.list` backed by a single SQL-paginated query with conditions joined in. - Reflinks' `findReflink` swallowed every error (including infra failures) as not-found; added a nullable `reflinkService.find()`. - Public flow imports attributed every import to the workspace owner; now pass `userId: null`, matching the contacts public-API precedent. - `triggerResource` published `z.array(z.any())` for conditions; conditions now has a real, documented shape. - `resendBroadcast` read a broadcast's contact filter before verifying it was resendable, and dropped the `deletedAt` predicate; added `broadcastService.assertResendable` to guard first. - `updateSequenceAction` masked a 404 as a generic 500 by wrapping the whole call in a catch-all; now rethrows `ChatbotXException` unchanged. - `aiTriggerService.list` divided pageCount by an unclamped `perPage`. Also: dedicated tests for `webhookService.updateWithConditions`, `sequence/step-payload.ts`'s defaulting logic, `broadcastService.create`'s insert shape, and bot-field unique-violation mapping; replaced two hand-rolled `UNIQUE_VIOLATION_CODE` checks with `isUniqueViolationError`; collapsed `templateSelectableResourceRepository`'s 11 near-identical list methods into one generic helper; removed dead double-mapping of trigger/ webhook conditions now that the service owns column normalization. --- .../__tests__/ai-agents-public-api.test.ts | 29 +- .../__tests__/flows-public-api.test.ts | 6 +- .../__tests__/public-spec-operations.test.ts | 2 +- .../__tests__/resend-broadcast.action.test.ts | 24 +- .../__tests__/triggers-public-api.test.ts | 92 +++-- .../__tests__/update-trigger-action.test.ts | 58 +-- .../__tests__/update-webhook-action.test.ts | 46 +-- .../__tests__/webhooks-public-api.test.ts | 23 +- .../src/features/ai-agents/api/public.ts | 4 +- .../src/features/ai-triggers/schema/query.ts | 4 +- .../update-automated-response-action.ts | 20 +- .../actions/resend-broadcast.action.ts | 9 + .../conditions/to-condition-columns.ts | 9 - apps/builder/src/features/flows/api/public.ts | 2 +- .../src/features/reflinks/queries/index.ts | 2 +- .../actions/update-sequence.action.ts | 8 + .../triggers/actions/update-trigger-action.ts | 9 +- .../src/features/triggers/api/public.ts | 57 ++- .../src/features/triggers/schema/resource.ts | 22 +- .../webhooks/actions/update-webhook-action.ts | 9 +- .../src/features/webhooks/api/public.ts | 3 +- .../automated-response.service.test.ts | 83 ++++ .../bot-field-unique-violation.test.ts | 161 ++++++++ .../broadcast-service-create.test.ts | 85 ++++ .../__tests__/sequence-service.test.ts | 22 +- .../__tests__/sequence-step-payload.test.ts | 169 ++++++++ .../trigger-service-update-settings.test.ts | 4 + ...ger-service-update-with-conditions.test.ts | 4 + .../__tests__/trigger.service.test.ts | 4 + ...ook-service-update-with-conditions.test.ts | 242 +++++++++++ packages/business/src/ai-agent/service.ts | 4 +- packages/business/src/ai-trigger/service.ts | 3 +- .../src/automated-response/service.ts | 53 ++- packages/business/src/bot-field/service.ts | 13 +- packages/business/src/broadcast/service.ts | 51 ++- packages/business/src/import/service.ts | 2 +- packages/business/src/reflink/service.ts | 14 +- packages/business/src/sequence/service.ts | 31 +- .../business/src/sequence/step-payload.ts | 14 +- packages/business/src/trigger/service.ts | 30 ++ ...ate-selectable-resource-repository.test.ts | 31 +- .../src/repositories/broadcast/repository.ts | 6 +- .../repository.ts | 387 +++--------------- .../src/repositories/trigger/repository.ts | 28 ++ 44 files changed, 1275 insertions(+), 604 deletions(-) delete mode 100644 apps/builder/src/features/conditions/to-condition-columns.ts create mode 100644 packages/business/__tests__/bot-field-unique-violation.test.ts create mode 100644 packages/business/__tests__/sequence-step-payload.test.ts create mode 100644 packages/business/__tests__/webhook-service-update-with-conditions.test.ts diff --git a/apps/builder/__tests__/ai-agents-public-api.test.ts b/apps/builder/__tests__/ai-agents-public-api.test.ts index fee38b36fb..3f4d9f1c9e 100644 --- a/apps/builder/__tests__/ai-agents-public-api.test.ts +++ b/apps/builder/__tests__/ai-agents-public-api.test.ts @@ -145,8 +145,11 @@ describe("GET /v1/ai-agents/{id}", () => { describe("POST /v1/ai-agents", () => { const procedure = findProcedure("POST", "/v1/ai-agents") - test("delegates to aiAgentService.create then re-fetches via findBy", async () => { - aiAgentService.create.mockResolvedValueOnce(undefined) + test("delegates to aiAgentService.create then re-fetches by the created id", async () => { + // Regression test: `create` returns the created id and the handler + // re-fetches by that id — not by `name`, which has no unique + // constraint and could match a pre-existing row on a duplicate name. + aiAgentService.create.mockResolvedValueOnce("agent-1") aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) await procedure.handler?.({ @@ -158,9 +161,29 @@ describe("POST /v1/ai-agents", () => { name: "Support agent", }) expect(aiAgentService.findBy).toHaveBeenCalledWith({ - where: { workspaceId: "workspace-1", name: "Support agent" }, + where: { id: "agent-1", workspaceId: "workspace-1" }, }) }) + + test("two creates with the same name return distinct ids", async () => { + aiAgentService.create + .mockResolvedValueOnce("agent-1") + .mockResolvedValueOnce("agent-2") + aiAgentService.findBy + .mockResolvedValueOnce({ id: "agent-1", name: "Support agent" }) + .mockResolvedValueOnce({ id: "agent-2", name: "Support agent" }) + + const first = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "Support agent" }, + }) + const second = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "Support agent" }, + }) + + expect(first.id).not.toBe(second.id) + }) }) describe("PUT /v1/ai-agents/{id}", () => { diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index 19f9d01121..fbacc04f28 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -300,7 +300,7 @@ describe("GET /v1/flows/{id}/versions", () => { describe("POST /v1/flows/import", () => { const procedure = findProcedure("POST", "/v1/flows/import") - test("delegates to importService.startFlowImport and queues the import job", async () => { + test("delegates to importService.startFlowImport with a null userId and queues the import job", async () => { importService.startFlowImport.mockResolvedValueOnce({ ok: true, importId: "import-1", @@ -313,9 +313,11 @@ describe("POST /v1/flows/import", () => { }), ).resolves.toEqual({ importId: "import-1" }) + // `userId: null` — matches the contacts public-API precedent so a + // token-initiated import is never mis-attributed to the workspace owner. expect(importService.startFlowImport).toHaveBeenCalledWith({ workspaceId: "workspace-1", - userId: "user-1", + userId: null, fileId: "file-1", folderId: null, }) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index c1fc4d61b9..31afb9b7e0 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -149,7 +149,7 @@ beforeAll(async () => { } operations.sort((a, b) => a.operationId.localeCompare(b.operationId)) -}) +}, 120_000) describe("public API spec — operation naming guard", () => { // Pins the MCP tool name / operationId surface. A diff here is a diff --git a/apps/builder/__tests__/resend-broadcast.action.test.ts b/apps/builder/__tests__/resend-broadcast.action.test.ts index f66b8eaf39..022581b386 100644 --- a/apps/builder/__tests__/resend-broadcast.action.test.ts +++ b/apps/builder/__tests__/resend-broadcast.action.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { mockResend, + mockAssertResendable, mockFindContactFilter, mockGetCurrentUserAndTargetWorkspace, } = vi.hoisted(() => ({ mockResend: vi.fn(), + mockAssertResendable: vi.fn().mockResolvedValue({ id: "bc-1" }), mockFindContactFilter: vi.fn(), mockGetCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ targetWorkspaceMember: { permissions: ["emailAndPhone"] }, @@ -23,7 +25,10 @@ vi.mock("@/lib/safe-action", () => { }) vi.mock("@chatbotx.io/business", () => ({ - broadcastService: { resend: mockResend }, + broadcastService: { + resend: mockResend, + assertResendable: mockAssertResendable, + }, })) vi.mock("@chatbotx.io/database/repositories", () => ({ @@ -59,6 +64,7 @@ const BROADCAST_ID = "bc-1" describe("resendBroadcast", () => { beforeEach(() => { vi.clearAllMocks() + mockAssertResendable.mockResolvedValue({ id: BROADCAST_ID }) mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({ targetWorkspaceMember: { permissions: ["emailAndPhone"] }, }) @@ -88,20 +94,28 @@ describe("resendBroadcast", () => { expect(result).toEqual({ id: "new-bc-id" }) }) - test("propagates a 'Broadcast is not sent' error from the service", async () => { - mockResend.mockRejectedValue(new Error("Broadcast is not sent")) + test("propagates a 'Broadcast is not sent' error from assertResendable, before reading the contact filter", async () => { + mockAssertResendable.mockRejectedValue(new Error("Broadcast is not sent")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), ).rejects.toThrow("Broadcast is not sent") + + // The guard runs before the contact-filter read — a not-resendable + // broadcast's filter is never touched. + expect(mockFindContactFilter).not.toHaveBeenCalled() + expect(mockResend).not.toHaveBeenCalled() }) - test("propagates a not-found error when the source broadcast is missing", async () => { - mockResend.mockRejectedValue(new Error("Record not found")) + test("propagates a not-found error when the source broadcast is missing, before reading the contact filter", async () => { + mockAssertResendable.mockRejectedValue(new Error("Record not found")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), ).rejects.toThrow("Record not found") + + expect(mockFindContactFilter).not.toHaveBeenCalled() + expect(mockResend).not.toHaveBeenCalled() }) test("passes undefined contactFilter when the source has none stored", async () => { diff --git a/apps/builder/__tests__/triggers-public-api.test.ts b/apps/builder/__tests__/triggers-public-api.test.ts index 56df299427..99b17750c6 100644 --- a/apps/builder/__tests__/triggers-public-api.test.ts +++ b/apps/builder/__tests__/triggers-public-api.test.ts @@ -47,7 +47,7 @@ const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) const triggerService = { - listByWorkspaceId: vi.fn(), + list: vi.fn(), create: vi.fn(), updateWithConditions: vi.fn(), updateSettings: vi.fn(), @@ -62,7 +62,13 @@ vi.mock("@chatbotx.io/business/errors", () => ({ const triggerRepository = { findWithConditions: vi.fn(), } -vi.mock("@chatbotx.io/database/repositories", () => ({ triggerRepository })) +const conditionRepository = { + listByTriggerIds: vi.fn(), +} +vi.mock("@chatbotx.io/database/repositories", () => ({ + triggerRepository, + conditionRepository, +})) vi.mock("@chatbotx.io/database/schema", () => { const schema = { @@ -102,14 +108,16 @@ test("registers the triggers public router under the automation scope", () => { describe("GET /v1/triggers", () => { const procedure = findProcedure("GET", "/v1/triggers") - test("returns real conditions and actions, not hardcoded empty arrays", async () => { - triggerService.listByWorkspaceId.mockResolvedValueOnce([ - { id: "trigger-1" }, - ]) - triggerRepository.findWithConditions.mockResolvedValueOnce({ - id: "trigger-1", - conditions: [{ id: "c1", type: "newContact" }], - actions: [{ id: "a1", type: "sendFlow" }], + test("returns real conditions and actions via a single paginated query", async () => { + triggerService.list.mockResolvedValueOnce({ + data: [ + { + id: "trigger-1", + conditions: [{ id: "c1", type: "newContact" }], + actions: [{ id: "a1", type: "sendFlow" }], + }, + ], + pageCount: 1, }) const result = await procedure.handler?.({ @@ -117,15 +125,16 @@ describe("GET /v1/triggers", () => { input: { page: 1, perPage: 50 }, }) - expect(triggerService.listByWorkspaceId).toHaveBeenCalledWith("workspace-1") - expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ - id: "trigger-1", + expect(triggerService.list).toHaveBeenCalledWith({ workspaceId: "workspace-1", + page: 1, + perPage: 50, }) expect(result.data[0].conditions).toEqual([ { id: "c1", type: "newContact" }, ]) expect(result.data[0].actions).toEqual([{ id: "a1", type: "sendFlow" }]) + expect(result.pageCount).toBe(1) }) }) @@ -184,17 +193,15 @@ describe("POST /v1/triggers", () => { describe("PUT /v1/triggers/{id}", () => { const procedure = findProcedure("PUT", "/v1/triggers/{id}") - test("delegates to triggerService.updateWithConditions", async () => { + test("delegates to triggerService.updateWithConditions and returns the service's result plus fresh conditions", async () => { triggerService.updateWithConditions.mockResolvedValueOnce({ id: "trigger-1", }) - triggerRepository.findWithConditions.mockResolvedValueOnce({ - id: "trigger-1", - conditions: [], - actions: [], - }) + conditionRepository.listByTriggerIds.mockResolvedValueOnce([ + { id: "c1", type: "newContact" }, + ]) - await procedure.handler?.({ + const result = await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, input: { id: "trigger-1", @@ -203,20 +210,20 @@ describe("PUT /v1/triggers/{id}", () => { }, }) + // Conditions pass through unmapped — the service's + // toConditionColumnsShared owns the column normalization now. expect(triggerService.updateWithConditions).toHaveBeenCalledWith({ workspaceId: "workspace-1", id: "trigger-1", actions: [{ type: "sendFlow" }], - conditions: [ - { - id: undefined, - type: "newContact", - sourceId: null, - operator: null, - value: null, - }, - ], + conditions: [{ type: "newContact" }], }) + // No redundant re-read of the trigger row itself — only conditions. + expect(triggerRepository.findWithConditions).not.toHaveBeenCalled() + expect(conditionRepository.listByTriggerIds).toHaveBeenCalledWith([ + "trigger-1", + ]) + expect(result.conditions).toEqual([{ id: "c1", type: "newContact" }]) }) test("throws not found when the trigger update fails to match", async () => { @@ -234,10 +241,16 @@ describe("PUT /v1/triggers/{id}", () => { describe("PATCH /v1/triggers/{id}/settings", () => { const procedure = findProcedure("PATCH", "/v1/triggers/{id}/settings") - test("delegates to triggerService.updateSettings", async () => { + test("delegates to triggerService.updateSettings and returns the updated resource", async () => { triggerService.updateSettings.mockResolvedValueOnce(undefined) + triggerRepository.findWithConditions.mockResolvedValueOnce({ + id: "trigger-1", + active: false, + conditions: [], + actions: [], + }) - await procedure.handler?.({ + const result = await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, input: { id: "trigger-1", active: false }, }) @@ -247,6 +260,23 @@ describe("PATCH /v1/triggers/{id}/settings", () => { id: "trigger-1", active: false, }) + expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ + id: "trigger-1", + workspaceId: "workspace-1", + }) + expect(result.active).toBe(false) + }) + + test("throws not found when the trigger no longer exists after updateSettings", async () => { + triggerService.updateSettings.mockResolvedValueOnce(undefined) + triggerRepository.findWithConditions.mockResolvedValueOnce(null) + + await expect( + procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "missing", active: false }, + }), + ).rejects.toThrow("Trigger not found") }) }) diff --git a/apps/builder/__tests__/update-trigger-action.test.ts b/apps/builder/__tests__/update-trigger-action.test.ts index 5160cf6611..8018702da5 100644 --- a/apps/builder/__tests__/update-trigger-action.test.ts +++ b/apps/builder/__tests__/update-trigger-action.test.ts @@ -18,20 +18,6 @@ vi.mock("@chatbotx.io/business", () => ({ triggerService: { updateWithConditions: mockUpdateWithConditions }, })) -vi.mock("@/features/conditions/to-condition-columns", () => ({ - toConditionColumns: (condition: { - type: string - sourceId?: string | null - operator?: string | null - value?: unknown - }) => ({ - type: condition.type, - sourceId: condition.sourceId ?? null, - operator: condition.operator ?? null, - value: condition.value ?? null, - }), -})) - vi.mock("../src/features/triggers/schema/mutation", () => ({ updateTriggerSchema: {}, })) @@ -61,21 +47,26 @@ describe("updateTriggerAction", () => { mockUpdateWithConditions.mockResolvedValue({ id: "trigger-1" }) }) - test("maps conditions via toConditionColumns and delegates to triggerService.updateWithConditions", async () => { + test("passes conditions through unmapped and delegates to triggerService.updateWithConditions", async () => { + // `toConditionColumnsShared` inside the service now owns the column + // normalization (`?? null` defaults) — the action forwards conditions + // as-is instead of mapping them a second time. + const conditions = [ + { + id: "condition-1", + type: "contact", + sourceId: "email", + operator: "eq", + value: "ada@example.com", + }, + { type: "contact", sourceId: "phone", operator: "exists" }, + ] + const result = await callAction({ bindArgsParsedInputs: ["workspace-1", "trigger-1"], parsedInput: { actions: [{ type: "startFlow", flowId: "flow-1" }], - conditions: [ - { - id: "condition-1", - type: "contact", - sourceId: "email", - operator: "eq", - value: "ada@example.com", - }, - { type: "contact", sourceId: "phone", operator: "exists" }, - ], + conditions, }, }) @@ -83,22 +74,7 @@ describe("updateTriggerAction", () => { workspaceId: "workspace-1", id: "trigger-1", actions: [{ type: "startFlow", flowId: "flow-1" }], - conditions: [ - { - id: "condition-1", - type: "contact", - sourceId: "email", - operator: "eq", - value: "ada@example.com", - }, - { - id: undefined, - type: "contact", - sourceId: "phone", - operator: "exists", - value: null, - }, - ], + conditions, }) expect(result).toEqual({ id: "trigger-1" }) }) diff --git a/apps/builder/__tests__/update-webhook-action.test.ts b/apps/builder/__tests__/update-webhook-action.test.ts index f836ec8136..390d2202d8 100644 --- a/apps/builder/__tests__/update-webhook-action.test.ts +++ b/apps/builder/__tests__/update-webhook-action.test.ts @@ -18,20 +18,6 @@ vi.mock("@chatbotx.io/business", () => ({ webhookService: { updateWithConditions: mockUpdateWithConditions }, })) -vi.mock("@/features/conditions/to-condition-columns", () => ({ - toConditionColumns: (c: { - type: string - sourceId?: string | null - operator?: string | null - value?: unknown - }) => ({ - type: c.type, - sourceId: c.sourceId ?? null, - operator: c.operator ?? null, - value: c.value ?? null, - }), -})) - vi.mock("../src/features/webhooks/schema/update-webhook-schema", () => ({ updateWebhookRequest: {}, })) @@ -64,15 +50,20 @@ beforeEach(() => { }) describe("updateWebhookAction", () => { - test("maps conditions via toConditionColumns and delegates to webhookService.updateWithConditions", async () => { + test("passes conditions through unmapped and delegates to webhookService.updateWithConditions", async () => { + // `toConditionColumnsShared` inside the service now owns the column + // normalization (`?? null` defaults) — the action forwards conditions + // as-is instead of mapping them a second time. + const conditions = [ + { id: "cond-1", type: "newContact" }, + { type: "tagApplied", sourceId: "tag-1" }, + ] + const result = await callAction({ bindArgsParsedInputs: ["ws-1", "webhook-1"], parsedInput: { url: "https://example.com/hook", - conditions: [ - { id: "cond-1", type: "newContact" }, - { type: "tagApplied", sourceId: "tag-1" }, - ], + conditions, }, }) @@ -80,22 +71,7 @@ describe("updateWebhookAction", () => { workspaceId: "ws-1", id: "webhook-1", url: "https://example.com/hook", - conditions: [ - { - id: "cond-1", - type: "newContact", - sourceId: null, - operator: null, - value: null, - }, - { - id: undefined, - type: "tagApplied", - sourceId: "tag-1", - operator: null, - value: null, - }, - ], + conditions, }) expect(result).toEqual({ id: "webhook-1", name: "New Order" }) }) diff --git a/apps/builder/__tests__/webhooks-public-api.test.ts b/apps/builder/__tests__/webhooks-public-api.test.ts index 231d948b14..4eef6429d5 100644 --- a/apps/builder/__tests__/webhooks-public-api.test.ts +++ b/apps/builder/__tests__/webhooks-public-api.test.ts @@ -129,18 +129,21 @@ describe("POST /v1/webhooks", () => { ) }) - test("maps conditions and delegates to webhookService.register", async () => { + test("passes conditions through unmapped and delegates to webhookService.register", async () => { + // `register` normalizes each condition's columns (`?? null` defaults) + // internally now — the handler forwards conditions as-is. webhookService.register.mockResolvedValueOnce({ id: "webhook-1" }) + const conditions = [ + { type: "newContact" }, + { type: "tagApplied", sourceId: "tag-1" }, + ] const result = await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, input: { name: "n8n trigger", url: "https://n8n.example.com/webhook/abc", - conditions: [ - { type: "newContact" }, - { type: "tagApplied", sourceId: "tag-1" }, - ], + conditions, }, }) @@ -149,15 +152,7 @@ describe("POST /v1/webhooks", () => { workspaceId: "workspace-1", name: "n8n trigger", url: "https://n8n.example.com/webhook/abc", - conditions: [ - { type: "newContact", sourceId: null, operator: null, value: null }, - { - type: "tagApplied", - sourceId: "tag-1", - operator: null, - value: null, - }, - ], + conditions, }) }) }) diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 1ab9c78475..9fce466209 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -69,9 +69,9 @@ export const aiAgentsPublicRouter = { .output(aiAgentResourceSchema) .errors(possibleErrorsOnCreatingResource) .handler(async ({ context, input }) => { - await aiAgentService.create(context.workspace.id, input) + const id = await aiAgentService.create(context.workspace.id, input) const created = await aiAgentService.findBy({ - where: { workspaceId: context.workspace.id, name: input.name }, + where: { id, workspaceId: context.workspace.id }, }) if (!created) { throw notFoundException("AI agent not found") diff --git a/apps/builder/src/features/ai-triggers/schema/query.ts b/apps/builder/src/features/ai-triggers/schema/query.ts index 65210d925d..0143aa39a5 100644 --- a/apps/builder/src/features/ai-triggers/schema/query.ts +++ b/apps/builder/src/features/ai-triggers/schema/query.ts @@ -21,9 +21,7 @@ export type ListAITriggersRequest = Awaited< workspaceId: string } -export type AITriggerResource = AITriggerModel - export type AITriggerCollection = { - data: AITriggerResource[] + data: AITriggerModel[] pageCount: number } diff --git a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts index 87a6375cfd..c727bee127 100644 --- a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts +++ b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts @@ -2,11 +2,9 @@ import { automatedResponseService, - flowService, type UpdateAutomatedResponseRequest, } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" -import { returnValidationErrors } from "next-safe-action" import { workspaceActionClient } from "@/lib/safe-action" import { updateAutomatedResponseRequest } from "../schema/action" @@ -31,20 +29,8 @@ export const updateAutomatedResponse = async ( id: ctx.id, }) - if (parsedInput.text?.length) { - parsedInput.flowId = undefined - } else if (parsedInput.flowId) { - const exists = await flowService.exists(ctx.workspaceId, parsedInput.flowId) - if (!exists) { - return returnValidationErrors(updateAutomatedResponseRequest, { - _errors: ["Validation Exception"], - flowId: { - _errors: ["Flow not found"], - }, - }) - } - parsedInput.text = null - } - + // `text`/`flowId` mutual-exclusion and cross-workspace `flowId` + // validation now live in `automatedResponseService.update` so every + // caller (this action and the public API) gets the same invariants. await automatedResponseService.update(ctx, parsedInput) } diff --git a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts index dfa01c3c14..a19c2529d2 100644 --- a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts @@ -23,6 +23,15 @@ export const resendBroadcast = async (ctx: { workspaceId: string id: string }) => { + // Verify the broadcast exists (not soft-deleted, in-workspace) and is in + // a resendable status before reading its `contactFilter` — main checked + // existence first; reading before the guard would let a foreign or + // deleted id be processed. + await broadcastService.assertResendable({ + workspaceId: ctx.workspaceId, + id: ctx.id, + }) + const userAndWorkspace = await getCurrentUserAndTargetWorkspace( ctx.workspaceId, ) diff --git a/apps/builder/src/features/conditions/to-condition-columns.ts b/apps/builder/src/features/conditions/to-condition-columns.ts deleted file mode 100644 index 618ec7df2d..0000000000 --- a/apps/builder/src/features/conditions/to-condition-columns.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ConditionInput } from "./schema" - -export const toConditionColumns = (condition: ConditionInput) => ({ - type: condition.type, - sourceId: "sourceId" in condition ? condition.sourceId : null, - operator: "operator" in condition ? condition.operator : null, - value: - "value" in condition && condition.value !== null ? condition.value : null, -}) diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index 2a69715f42..70983b76f8 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -224,7 +224,7 @@ export const flowsPublicRouter = { .handler(async ({ context, input }) => { const result = await importService.startFlowImport({ workspaceId: context.workspace.id, - userId: context.workspace.ownerId, + userId: null, fileId: input.fileId, folderId: input.folderId, }) diff --git a/apps/builder/src/features/reflinks/queries/index.ts b/apps/builder/src/features/reflinks/queries/index.ts index 43b5e273f8..964471f03c 100644 --- a/apps/builder/src/features/reflinks/queries/index.ts +++ b/apps/builder/src/features/reflinks/queries/index.ts @@ -18,5 +18,5 @@ export async function listReflinks( export async function findReflink( where: GetReflinkRequest, ): Promise { - return await reflinkService.findOrFail(where).catch(() => undefined) + return (await reflinkService.find(where)) ?? undefined } diff --git a/apps/builder/src/features/sequences/actions/update-sequence.action.ts b/apps/builder/src/features/sequences/actions/update-sequence.action.ts index 8af5085e50..dd58442429 100644 --- a/apps/builder/src/features/sequences/actions/update-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/update-sequence.action.ts @@ -1,6 +1,7 @@ "use server" import { sequenceService } from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" @@ -34,6 +35,13 @@ export const updateSequenceAction = workspaceActionClient }) } + // A `ChatbotXException` (e.g. not-found) already carries a correct + // status/message — rethrow it unchanged so it doesn't get masked as + // a generic 500. Only genuinely unknown errors get wrapped. + if (error instanceof ChatbotXException) { + throw error + } + throw new Error("Failed to update sequence") } }) diff --git a/apps/builder/src/features/triggers/actions/update-trigger-action.ts b/apps/builder/src/features/triggers/actions/update-trigger-action.ts index fd0f8ab0be..d3776480b1 100644 --- a/apps/builder/src/features/triggers/actions/update-trigger-action.ts +++ b/apps/builder/src/features/triggers/actions/update-trigger-action.ts @@ -2,7 +2,6 @@ import { triggerService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" -import { toConditionColumns } from "@/features/conditions/to-condition-columns" import { workspaceActionClient } from "@/lib/safe-action" import { updateTriggerSchema } from "../schema/mutation" @@ -16,13 +15,13 @@ export const updateTriggerAction = workspaceActionClient } = props const { conditions, actions } = parsedInput + // `toConditionColumnsShared` inside `updateWithConditions` already + // normalizes each condition's columns (`?? null` defaults) — mapping + // again here was dead work now that the service owns it. return await triggerService.updateWithConditions({ workspaceId, id, actions, - conditions: conditions.map((condition) => ({ - id: "id" in condition ? condition.id : undefined, - ...toConditionColumns(condition), - })), + conditions, }) }) diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 63b77846db..50263469a4 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -1,11 +1,13 @@ import { triggerService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { folderTypes } from "@chatbotx.io/database/partials" -import { triggerRepository } from "@chatbotx.io/database/repositories" +import { + conditionRepository, + triggerRepository, +} from "@chatbotx.io/database/repositories" import type { TriggerModel } from "@chatbotx.io/database/types" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" -import { toConditionColumns } from "@/features/conditions/to-condition-columns" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -13,11 +15,7 @@ import { possibleErrorsOnListingResource, possibleErrorsOnMutatingResource, } from "@/lib/orpc/orpc-error-helper" -import { - paginateInMemory, - publicListRequest, - publicListResponse, -} from "@/lib/public-api/list" +import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { createTriggerSchema, updateTriggerSchema } from "../schema/mutation" import { triggerResource } from "../schema/resource" @@ -52,21 +50,12 @@ export const triggersPublicRouter = { .output(publicListResponse(triggerResource)) .errors(possibleErrorsOnListingResource) .handler(async ({ context, input }) => { - const triggers = await triggerService.listByWorkspaceId( - context.workspace.id, - ) - const withConditions = await Promise.all( - triggers.map((trigger) => - triggerRepository.findWithConditions({ - id: trigger.id, - workspaceId: context.workspace.id, - }), - ), - ) - return paginateInMemory( - withConditions.filter((trigger) => trigger !== null).map(toResource), - input, - ) + const { data, pageCount } = await triggerService.list({ + workspaceId: context.workspace.id, + page: input.page, + perPage: input.perPage, + }) + return { data: data.map(toResource), pageCount } }), get: workspaceTokenAuthAPI @@ -129,22 +118,13 @@ export const triggersPublicRouter = { workspaceId: context.workspace.id, id, actions, - conditions: conditions.map((condition) => ({ - id: "id" in condition ? condition.id : undefined, - ...toConditionColumns(condition), - })), + conditions, }) if (!updated) { throw notFoundException("Trigger not found") } - const withConditions = await triggerRepository.findWithConditions({ - id, - workspaceId: context.workspace.id, - }) - if (!withConditions) { - throw notFoundException("Trigger not found") - } - return toResource(withConditions) + const updatedConditions = await conditionRepository.listByTriggerIds([id]) + return toResource({ ...updated, conditions: updatedConditions }) }), updateSettings: workspaceTokenAuthAPI @@ -161,6 +141,7 @@ export const triggersPublicRouter = { active: z.boolean().optional(), }), ) + .output(triggerResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, ...patch } = input @@ -169,6 +150,14 @@ export const triggersPublicRouter = { id, ...patch, }) + const updated = await triggerRepository.findWithConditions({ + id, + workspaceId: context.workspace.id, + }) + if (!updated) { + throw notFoundException("Trigger not found") + } + return toResource(updated) }), delete: workspaceTokenAuthAPI diff --git a/apps/builder/src/features/triggers/schema/resource.ts b/apps/builder/src/features/triggers/schema/resource.ts index 98f58f22ec..4632cf4cfc 100644 --- a/apps/builder/src/features/triggers/schema/resource.ts +++ b/apps/builder/src/features/triggers/schema/resource.ts @@ -1,12 +1,30 @@ import { createSelectSchema, triggerModel } from "@chatbotx.io/database/schema" import { z } from "zod" +// Real shapes instead of `z.any()` so the published OpenAPI spec documents +// an actual condition/action row — loose on `type`/`value` because a +// condition row's `value` shape varies per `type` (see +// `features/conditions/schema` for the full discriminated union used by +// the mutation side) and this is a read-only resource, not a write schema. +const conditionRowResource = z.object({ + id: z.string(), + type: z.string(), + sourceId: z.string().nullable(), + operator: z.string().nullable(), + value: z.unknown(), +}) + export const triggerResource = createSelectSchema(triggerModel, { id: z.string(), workspaceId: z.string(), folderId: z.string().nullable(), }).extend({ - conditions: z.array(z.any()), - actions: z.array(z.any()), + conditions: z.array(conditionRowResource), + // The DB column is jsonb with no shape guarantee at the type level + // (`TriggerModel["actions"]` is `unknown[]`); `allActions`'s discriminated + // union documents the write-side shape (`components/actions/schema`), but + // reusing it here as a read-side assertion would break every private + // caller returning a raw DB row whose `actions` type is `unknown[]`. + actions: z.array(z.unknown()), }) export type TriggerResource = z.infer diff --git a/apps/builder/src/features/webhooks/actions/update-webhook-action.ts b/apps/builder/src/features/webhooks/actions/update-webhook-action.ts index dd8ceedcad..d1fe11f8a7 100644 --- a/apps/builder/src/features/webhooks/actions/update-webhook-action.ts +++ b/apps/builder/src/features/webhooks/actions/update-webhook-action.ts @@ -2,7 +2,6 @@ import { webhookService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" -import { toConditionColumns } from "@/features/conditions/to-condition-columns" import { workspaceActionClient } from "@/lib/safe-action" import { updateWebhookRequest } from "../schema/update-webhook-schema" @@ -16,13 +15,13 @@ export const updateWebhookAction = workspaceActionClient } = props const { conditions, url } = parsedInput + // `toConditionColumnsShared` inside `updateWithConditions` already + // normalizes each condition's columns (`?? null` defaults) — mapping + // again here was dead work now that the service owns it. return await webhookService.updateWithConditions({ workspaceId, id, url, - conditions: conditions.map((condition) => ({ - id: "id" in condition ? condition.id : undefined, - ...toConditionColumns(condition), - })), + conditions, }) }) diff --git a/apps/builder/src/features/webhooks/api/public.ts b/apps/builder/src/features/webhooks/api/public.ts index 2433635696..c50ff2e04a 100644 --- a/apps/builder/src/features/webhooks/api/public.ts +++ b/apps/builder/src/features/webhooks/api/public.ts @@ -15,7 +15,6 @@ import { import { workspaceTokenAuthAPIForScope } from "@/orpc" import { conditionSchema } from "../../conditions/schema" -import { toConditionColumns } from "../../conditions/to-condition-columns" import { publicWebhookResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("integrations") @@ -68,7 +67,7 @@ export const webhooksPublicRouter = { workspaceId: context.workspace.id, name, url, - conditions: conditions.map(toConditionColumns), + conditions, }) }), diff --git a/packages/business/__tests__/automated-response.service.test.ts b/packages/business/__tests__/automated-response.service.test.ts index 5f05f1dbb3..9d58010610 100644 --- a/packages/business/__tests__/automated-response.service.test.ts +++ b/packages/business/__tests__/automated-response.service.test.ts @@ -166,6 +166,89 @@ describe("automatedResponseService audit side effects", () => { }) }) +describe("automatedResponseService.update — keywords and flowId/text invariants", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.findFirst.mockResolvedValue({ + folderId: null, + keywords: ["hello", "hi"], + text: null, + flowId: null, + status: true, + }) + mocks.updateReturning.mockResolvedValue([ + { id: "automation-1", keywords: ["hello", "hi"] }, + ]) + }) + + // Regression: PUT /v1/keywords/{id} with only `{ text }` used to + // unconditionally set keywords to `[]`, silently wiping the automation. + test("omitting keywords does not wipe the existing keywords column", async () => { + await automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1" }, + { text: "hi" }, + ) + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.not.objectContaining({ keywords: expect.anything() }), + ) + }) + + test("explicitly supplied keywords are still applied", async () => { + await automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1" }, + { keywords: [{ value: "new" }] }, + ) + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ keywords: ["new"] }), + ) + }) + + test("nulls flowId when text is set", async () => { + await automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1" }, + { text: "hi", flowId: "flow-1" }, + ) + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ flowId: null, text: "hi" }), + ) + expect(mocks.flowExists).not.toHaveBeenCalled() + }) + + test("validates flowId against the workspace and nulls text when flowId is set", async () => { + mocks.flowExists.mockResolvedValue(true) + + await automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1" }, + { flowId: "flow-1" }, + ) + + expect(mocks.flowExists).toHaveBeenCalledWith( + "workspace-1", + "flow-1", + undefined, + ) + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ flowId: "flow-1", text: null }), + ) + }) + + test("rejects a flowId that does not belong to the workspace", async () => { + mocks.flowExists.mockResolvedValue(false) + + await expect( + automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1" }, + { flowId: "foreign-flow" }, + ), + ).rejects.toMatchObject({ field: "flowId", message: "Flow not found" }) + + expect(mocks.updateSet).not.toHaveBeenCalled() + }) +}) + describe("automatedResponseService.create — flowId XOR text", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/business/__tests__/bot-field-unique-violation.test.ts b/packages/business/__tests__/bot-field-unique-violation.test.ts new file mode 100644 index 0000000000..77c6cd8f51 --- /dev/null +++ b/packages/business/__tests__/bot-field-unique-violation.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node + +import { afterEach, describe, expect, test, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + insertValues: vi.fn(), + insertReturning: vi.fn(), + updateSet: vi.fn(), + updateWhere: vi.fn(), + updateReturning: vi.fn(), + invalidateCacheTags: vi.fn(), + withCacheInvalidate: vi.fn(), +})) + +const botFieldModel = { + id: "BOT_FIELD_ID_COL", + workspaceId: "BOT_FIELD_WORKSPACE_ID_COL", + name: "BOT_FIELD_NAME_COL", +} + +class UniqueViolationError extends Error { + cause = { code: "23505" } +} + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { + query: { + botFieldModel: { findFirst: mocks.findFirst }, + }, + insert: vi.fn(() => ({ + values: (value: unknown) => { + mocks.insertValues(value) + return { returning: mocks.insertReturning } + }, + })), + update: vi.fn(() => ({ + set: (setValue: unknown) => { + mocks.updateSet(setValue) + return { + where: (whereValue: unknown) => { + mocks.updateWhere(whereValue) + return { returning: mocks.updateReturning } + }, + } + }, + })), + }, + and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + inArray: vi.fn(), + isUniqueViolationError: (error: unknown) => + error instanceof UniqueViolationError, + relationsFilterToSQL: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + rootFolderId: "root", +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + botFieldModel, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + likeContains: (value: string) => value, + parseOrderByAsObject: vi.fn(), + parsePagination: vi.fn(), +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: (_key: string, fn: () => unknown) => fn(), +})) + +vi.mock("../src/base.service", () => ({ + BaseService: class BaseService { + invalidateCacheTags(...args: unknown[]) { + return mocks.invalidateCacheTags(...args) + } + invalidate(...args: unknown[]) { + return mocks.withCacheInvalidate(...args) + } + }, +})) + +vi.mock("../src/errors", () => ({ + notFoundException: (message: string) => new Error(message), + validationException: (field: string, message: string) => { + const error = new Error(message) as Error & { + code: string + field: string + } + error.code = "validation" + error.field = field + return error + }, + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("../src/folder/service", () => ({ + folderService: { ensureExists: vi.fn() }, +})) + +const { botFieldService } = await import("../src/bot-field/service") + +describe("botFieldService — unique-violation mapping", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("create maps a 23505 unique violation to validationException(name)", async () => { + mocks.insertReturning.mockRejectedValue(new UniqueViolationError()) + + await expect( + botFieldService.create({ + workspaceId: "ws-1", + data: { name: "Existing Field", type: "shortText" }, + }), + ).rejects.toMatchObject({ + code: "validation", + field: "name", + message: "Name is already taken", + }) + }) + + test("create rethrows a non-unique-violation database error unchanged", async () => { + const otherError = new Error("connection reset") + mocks.insertReturning.mockRejectedValue(otherError) + + await expect( + botFieldService.create({ + workspaceId: "ws-1", + data: { name: "New Field", type: "shortText" }, + }), + ).rejects.toBe(otherError) + }) + + test("updateByKey (via persistUpdate) maps a 23505 unique violation to validationException(name)", async () => { + mocks.findFirst.mockResolvedValue({ + id: "field-1", + workspaceId: "ws-1", + name: "field", + type: "shortText", + value: null, + folderId: null, + }) + mocks.updateReturning.mockRejectedValue(new UniqueViolationError()) + + await expect( + botFieldService.updateByKey({ + workspaceId: "ws-1", + key: "field-1", + data: { name: "Taken Name" }, + }), + ).rejects.toMatchObject({ + code: "validation", + field: "name", + message: "Name is already taken", + }) + }) +}) diff --git a/packages/business/__tests__/broadcast-service-create.test.ts b/packages/business/__tests__/broadcast-service-create.test.ts index 04f2a767b3..a3e0fa671a 100644 --- a/packages/business/__tests__/broadcast-service-create.test.ts +++ b/packages/business/__tests__/broadcast-service-create.test.ts @@ -274,4 +274,89 @@ describe("broadcastService.create — validation branches", () => { expect.objectContaining({ action: "launch" }), ) }) + + test("persists the expected insert shape", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + findFirstIntegrationMessenger.mockResolvedValue({ id: "integration-1" }) + mockPruneFilter.mockReturnValue({ pruned: true }) + + const schedulesAt = new Date("2026-01-01T10:30:45.123Z") + + await broadcastService.create({ + ...baseInput, + integrationMessengerId: "integration-1", + schedulesAt, + contactFilter: { raw: true } as never, + templateData: { header: "hi" } as never, + buttons: [{ label: "Click" }] as never, + saveAsDraft: false, + } as never) + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + name: "My Flow", + status: "scheduled", + integrationMessengerId: "integration-1", + workspaceId: WS, + // startOfMinute(...) — seconds/ms zeroed + schedulesAt: new Date("2026-01-01T10:30:00.000Z"), + contactFilter: { pruned: true }, + templateData: { header: "hi", buttons: [{ label: "Click" }] }, + }), + ) + }) + + test("draft status is persisted from saveAsDraft", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + + await broadcastService.create({ ...baseInput, saveAsDraft: true }) + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ status: "draft" }), + ) + }) + + test("templateData is null when not supplied", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + + await broadcastService.create(baseInput) + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ templateData: null }), + ) + }) + + test("pruneEmailPhoneFilterConditions is applied to contactFilter", async () => { + mockFindCapability.mockReturnValue({ + subactions: ["sendMessage"], + supportsTemplateBroadcast: false, + }) + findFirstFlow.mockResolvedValue({ id: "flow-1", name: "My Flow" }) + mockPruneFilter.mockReturnValue({ pruned: "yes" }) + + await broadcastService.create({ + ...baseInput, + contactFilter: { raw: "criteria" } as never, + } as never) + + expect(mockPruneFilter).toHaveBeenCalledWith( + { raw: "criteria" }, + true, // canViewEmailAndPhone from baseInput + ) + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ contactFilter: { pruned: "yes" } }), + ) + }) }) diff --git a/packages/business/__tests__/sequence-service.test.ts b/packages/business/__tests__/sequence-service.test.ts index 425fb498f6..4bf7865b31 100644 --- a/packages/business/__tests__/sequence-service.test.ts +++ b/packages/business/__tests__/sequence-service.test.ts @@ -7,7 +7,7 @@ const { mockInsert, mockInsertValues, mockFindOrFail, - mockIsDatabaseError, + mockIsUniqueViolationError, mockDelete, mockDispatchAuditRecord, mockStepFindFirst, @@ -49,7 +49,7 @@ const { mockInsert, mockInsertValues, mockFindOrFail: vi.fn(), - mockIsDatabaseError: vi.fn(() => false), + mockIsUniqueViolationError: vi.fn(() => false), mockDelete, mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), mockStepFindFirst: vi.fn(), @@ -80,7 +80,7 @@ vi.mock("@chatbotx.io/database/client", () => ({ and: (...args: unknown[]) => ({ and: args }), eq: (...args: unknown[]) => ({ eq: args }), findOrFail: mockFindOrFail, - isDatabaseError: mockIsDatabaseError, + isUniqueViolationError: mockIsUniqueViolationError, })) vi.mock("@chatbotx.io/database/schema", () => ({ @@ -126,7 +126,7 @@ describe("sequenceService.create", () => { cause: { code: "23505" }, }) mockInsertValues.mockRejectedValueOnce(dbError) - mockIsDatabaseError.mockReturnValueOnce(true) + mockIsUniqueViolationError.mockReturnValueOnce(true) await expect( sequenceService.create({ workspaceId: WS, name: "Duplicate" }), @@ -142,7 +142,7 @@ describe("sequenceService.create", () => { cause: { code: "XXXXX" }, }) mockInsertValues.mockRejectedValueOnce(dbError) - mockIsDatabaseError.mockReturnValueOnce(true) + mockIsUniqueViolationError.mockReturnValueOnce(false) await expect( sequenceService.create({ workspaceId: WS, name: "Seq" }), @@ -223,7 +223,7 @@ describe("sequenceService.update", () => { cause: { code: "23505" }, }) mockStepUpdateReturning.mockRejectedValueOnce(dbError) - mockIsDatabaseError.mockReturnValueOnce(true) + mockIsUniqueViolationError.mockReturnValueOnce(true) await expect( sequenceService.update( @@ -293,20 +293,22 @@ describe("sequenceService.updateStep / deleteStep cross-workspace rejection", () ).rejects.toThrow("Step not found") }) - test("updateStep throws when the step belongs to a different workspace", async () => { + test("updateStep throws not-found (not an ownership-revealing message) for a step in a different workspace", async () => { mockStepFindFirst.mockResolvedValue({ id: "step-1", order: 1, sequence: { workspaceId: "other-ws" }, }) + // Masked as "not found" rather than an "Unauthorized" message, so a + // caller can't distinguish a missing step from a foreign one. await expect( sequenceService.updateStep({ workspaceId: WS, stepId: "step-1", data: { order: 0 }, }), - ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") + ).rejects.toThrow("Step not found") }) test("deleteStep throws when the step does not exist", async () => { @@ -317,7 +319,7 @@ describe("sequenceService.updateStep / deleteStep cross-workspace rejection", () ).rejects.toThrow("Step not found") }) - test("deleteStep throws when the step belongs to a different workspace", async () => { + test("deleteStep throws not-found (not an ownership-revealing message) for a step in a different workspace", async () => { mockStepFindFirst.mockResolvedValue({ id: "step-1", sequence: { workspaceId: "other-ws" }, @@ -325,7 +327,7 @@ describe("sequenceService.updateStep / deleteStep cross-workspace rejection", () await expect( sequenceService.deleteStep({ workspaceId: WS, stepId: "step-1" }), - ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") + ).rejects.toThrow("Step not found") expect(mockStepDelete).not.toHaveBeenCalled() }) diff --git a/packages/business/__tests__/sequence-step-payload.test.ts b/packages/business/__tests__/sequence-step-payload.test.ts new file mode 100644 index 0000000000..3e78e44603 --- /dev/null +++ b/packages/business/__tests__/sequence-step-payload.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment node + +import { describe, expect, test } from "vitest" +import { buildCreateData, buildUpdateData } from "../src/sequence/step-payload" + +describe("buildCreateData", () => { + test("applies defaults for every optional field when omitted", () => { + const result = buildCreateData({ order: 1 }, "seq-1", "step-1") + + expect(result).toEqual({ + id: "step-1", + sequenceId: "seq-1", + order: 1, + delayDays: 1, + delayMinutes: 0, + delayUnit: "days", + flowId: null, + specificDateTime: null, + isActive: true, + anytime: true, + sendTimeStart: null, + sendTimeEnd: null, + sendDays: null, + }) + }) + + test("keeps explicit values instead of defaults", () => { + const result = buildCreateData( + { + order: 2, + delayDays: 5, + delayMinutes: 30, + delayUnit: "hours", + flowId: "flow-1", + isActive: false, + anytime: false, + }, + "seq-1", + "step-1", + ) + + expect(result).toMatchObject({ + delayDays: 5, + delayMinutes: 30, + delayUnit: "hours", + flowId: "flow-1", + isActive: false, + anytime: false, + }) + }) + + test("converts specificDateTime string to a Date", () => { + const result = buildCreateData( + { order: 1, specificDateTime: "2026-01-01T10:00:00.000Z" }, + "seq-1", + "step-1", + ) + + expect(result.specificDateTime).toEqual( + new Date("2026-01-01T10:00:00.000Z"), + ) + }) + + test("nulls specificDateTime when omitted or empty", () => { + expect( + buildCreateData({ order: 1, specificDateTime: null }, "seq-1", "step-1") + .specificDateTime, + ).toBeNull() + expect( + buildCreateData({ order: 1, specificDateTime: "" }, "seq-1", "step-1") + .specificDateTime, + ).toBeNull() + }) + + test("converts empty-string sendTimeStart/sendTimeEnd to null (|| semantics)", () => { + const result = buildCreateData( + { order: 1, sendTimeStart: "", sendTimeEnd: "" }, + "seq-1", + "step-1", + ) + + expect(result.sendTimeStart).toBeNull() + expect(result.sendTimeEnd).toBeNull() + }) + + test("keeps non-empty sendTimeStart/sendTimeEnd", () => { + const result = buildCreateData( + { order: 1, sendTimeStart: "09:00", sendTimeEnd: "17:00" }, + "seq-1", + "step-1", + ) + + expect(result.sendTimeStart).toBe("09:00") + expect(result.sendTimeEnd).toBe("17:00") + }) + + test("serializes sendDays to JSON, or null when omitted", () => { + expect( + buildCreateData({ order: 1, sendDays: ["mon", "tue"] }, "seq-1", "step-1") + .sendDays, + ).toBe(JSON.stringify(["mon", "tue"])) + expect(buildCreateData({ order: 1 }, "seq-1", "step-1").sendDays).toBeNull() + }) +}) + +describe("buildUpdateData", () => { + test("omits every field the caller did not supply (no defaulting)", () => { + const result = buildUpdateData({ order: 3 }) + + expect(result).toEqual({ order: 3 }) + }) + + test("only includes fields explicitly present in the input", () => { + const result = buildUpdateData({ + order: 1, + delayDays: 2, + isActive: false, + }) + + expect(result).toEqual({ order: 1, delayDays: 2, isActive: false }) + expect(result).not.toHaveProperty("delayMinutes") + expect(result).not.toHaveProperty("delayUnit") + }) + + test("converts specificDateTime string to Date when present", () => { + const result = buildUpdateData({ + order: 1, + specificDateTime: "2026-06-15T08:00:00.000Z", + }) + + expect(result.specificDateTime).toEqual( + new Date("2026-06-15T08:00:00.000Z"), + ) + }) + + test("nulls specificDateTime when explicitly set to null or empty string", () => { + expect( + buildUpdateData({ order: 1, specificDateTime: null }).specificDateTime, + ).toBeNull() + expect( + buildUpdateData({ order: 1, specificDateTime: "" }).specificDateTime, + ).toBeNull() + }) + + test("converts empty-string sendTimeStart/sendTimeEnd to null when present", () => { + const result = buildUpdateData({ + order: 1, + sendTimeStart: "", + sendTimeEnd: "", + }) + + expect(result.sendTimeStart).toBeNull() + expect(result.sendTimeEnd).toBeNull() + }) + + test("serializes sendDays to JSON when present", () => { + const result = buildUpdateData({ order: 1, sendDays: ["wed"] }) + + expect(result.sendDays).toBe(JSON.stringify(["wed"])) + }) + + test("nulls sendDays when explicitly set to an empty array is not the same as omitted", () => { + const result = buildUpdateData({ order: 1, sendDays: [] }) + + // `sendDays ? JSON.stringify(sendDays) : null` — an empty array is + // truthy, so it serializes rather than nulling. + expect(result.sendDays).toBe(JSON.stringify([])) + }) +}) diff --git a/packages/business/__tests__/trigger-service-update-settings.test.ts b/packages/business/__tests__/trigger-service-update-settings.test.ts index d92780bdc2..519d309f2e 100644 --- a/packages/business/__tests__/trigger-service-update-settings.test.ts +++ b/packages/business/__tests__/trigger-service-update-settings.test.ts @@ -38,6 +38,10 @@ vi.mock("@chatbotx.io/database/schema", () => ({ triggerModel: { id: "triggerModel.id" }, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + triggerRepository: { listPaginatedWithConditions: vi.fn() }, +})) + vi.mock("@chatbotx.io/events", () => ({ removeTriggerCache: vi.fn(), updateTriggerCache: vi.fn(), diff --git a/packages/business/__tests__/trigger-service-update-with-conditions.test.ts b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts index 810f084ba8..8fa692ed97 100644 --- a/packages/business/__tests__/trigger-service-update-with-conditions.test.ts +++ b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts @@ -70,6 +70,10 @@ vi.mock("@chatbotx.io/database/schema", () => ({ }, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + triggerRepository: { listPaginatedWithConditions: vi.fn() }, +})) + vi.mock("@chatbotx.io/events", () => ({ removeTriggerCache: vi.fn(), updateTriggerCache: mockUpdateTriggerCache, diff --git a/packages/business/__tests__/trigger.service.test.ts b/packages/business/__tests__/trigger.service.test.ts index f89d5f7990..c7cb94fc00 100644 --- a/packages/business/__tests__/trigger.service.test.ts +++ b/packages/business/__tests__/trigger.service.test.ts @@ -60,6 +60,10 @@ vi.mock("@chatbotx.io/database/schema", () => ({ triggerModel: { id: "id", workspaceId: "workspaceId" }, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + triggerRepository: { listPaginatedWithConditions: vi.fn() }, +})) + vi.mock("@chatbotx.io/events", () => ({ removeTriggerCache: mockRemoveTriggerCache, updateTriggerCache: mockUpdateTriggerCache, diff --git a/packages/business/__tests__/webhook-service-update-with-conditions.test.ts b/packages/business/__tests__/webhook-service-update-with-conditions.test.ts new file mode 100644 index 0000000000..8f41092678 --- /dev/null +++ b/packages/business/__tests__/webhook-service-update-with-conditions.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateId, + mockDbTransaction, + mockConditionFindMany, + mockWebhookFindFirst, + mockTxUpdate, + mockTxUpdateSet, + mockTxDelete, + mockTxDeleteWhere, + mockTxInsert, + mockTxInsertValues, + mockUpdateWebhookCache, + mockDispatchAuditRecord, +} = vi.hoisted(() => { + const mockTxUpdateWhere = vi.fn().mockResolvedValue(undefined) + const mockTxUpdateSet = vi.fn().mockReturnValue({ where: mockTxUpdateWhere }) + const mockTxUpdate = vi.fn().mockReturnValue({ set: mockTxUpdateSet }) + const mockTxDeleteWhere = vi.fn().mockResolvedValue(undefined) + const mockTxDelete = vi.fn().mockReturnValue({ where: mockTxDeleteWhere }) + const mockTxInsertValues = vi.fn().mockResolvedValue(undefined) + const mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }) + + return { + mockCreateId: vi.fn(() => "new-condition-id"), + mockDbTransaction: vi.fn(), + mockConditionFindMany: vi.fn(), + mockWebhookFindFirst: vi.fn(), + mockTxUpdate, + mockTxUpdateSet, + mockTxDelete, + mockTxDeleteWhere, + mockTxInsert, + mockTxInsertValues, + mockUpdateWebhookCache: vi.fn().mockResolvedValue(undefined), + mockDispatchAuditRecord: vi.fn().mockResolvedValue(undefined), + } +}) + +const tx = { + query: { + conditionModel: { findMany: mockConditionFindMany }, + webhookModel: { findFirst: mockWebhookFindFirst }, + }, + update: mockTxUpdate, + delete: mockTxDelete, + insert: mockTxInsert, +} + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: mockDbTransaction }, + and: (...args: unknown[]) => ({ and: args }), + eq: (...args: unknown[]) => ({ eq: args }), + inArray: (...args: unknown[]) => ({ inArray: args }), +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + conditionModel: { id: "conditionModel.id" }, + webhookModel: { + id: "webhookModel.id", + workspaceId: "webhookModel.workspaceId", + }, +})) + +vi.mock("@chatbotx.io/events", () => ({ + removeWebhookCache: vi.fn(), + updateWebhookCache: mockUpdateWebhookCache, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + distributedLock: vi.fn( + async (_key: string, fn: () => Promise) => await fn(), + ), +})) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: mockCreateId, +})) + +vi.mock("../src/folder/service", () => ({ + folderService: { ensureExists: vi.fn() }, +})) + +vi.mock("../src/net/ssrf-guard", () => ({ + assertPublicUrl: vi.fn(), +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: mockDispatchAuditRecord, +})) + +const { webhookService } = await import("../src/webhook/service") + +const WS = "ws-1" +const WEBHOOK_ID = "webhook-1" +const URL = "https://example.com/hook" + +describe("webhookService.updateWithConditions", () => { + beforeEach(() => { + mockDbTransaction.mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(tx), + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test("partitions conditions into delete/update/create and applies each", async () => { + mockConditionFindMany.mockResolvedValue([ + { + id: "cond-keep", + type: "tagApplied", + sourceId: "tag-old", + operator: null, + value: null, + }, + { + id: "cond-delete", + type: "tagApplied", + sourceId: "tag-2", + operator: null, + value: null, + }, + ]) + mockWebhookFindFirst.mockResolvedValue({ id: WEBHOOK_ID }) + + await webhookService.updateWithConditions({ + workspaceId: WS, + id: WEBHOOK_ID, + url: URL, + conditions: [ + { + id: "cond-keep", + type: "tagApplied", + sourceId: "tag-new", + }, + { type: "newContact" }, + ], + }) + + // deletes the condition not resubmitted + expect(mockTxDelete).toHaveBeenCalledWith({ id: "conditionModel.id" }) + expect(mockTxDeleteWhere).toHaveBeenCalledWith({ + inArray: ["conditionModel.id", ["cond-delete"]], + }) + + // updates the resubmitted condition unconditionally — no isSameJsonValue + // diff-skip, unlike triggerService.updateWithConditions + expect(mockTxUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ sourceId: "tag-new" }), + ) + + // creates the new condition + expect(mockTxInsertValues).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "new-condition-id", + webhookId: WEBHOOK_ID, + type: "newContact", + }), + ]) + }) + + test("updates every resubmitted condition unconditionally, even when nothing changed", async () => { + mockConditionFindMany.mockResolvedValue([ + { + id: "cond-unchanged", + type: "tagApplied", + sourceId: "tag-same", + operator: null, + value: null, + }, + ]) + mockWebhookFindFirst.mockResolvedValue({ id: WEBHOOK_ID }) + + await webhookService.updateWithConditions({ + workspaceId: WS, + id: WEBHOOK_ID, + url: URL, + conditions: [ + { id: "cond-unchanged", type: "tagApplied", sourceId: "tag-same" }, + ], + }) + + // Unlike triggerService, webhookService has no isSameJsonValue diff-skip + // — a resubmitted condition is always re-written. + expect(mockTxUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ sourceId: "tag-same" }), + ) + }) + + test("always refreshes the cache, regardless of whether anything changed", async () => { + mockConditionFindMany.mockResolvedValue([]) + mockWebhookFindFirst.mockResolvedValue({ id: WEBHOOK_ID }) + + await webhookService.updateWithConditions({ + workspaceId: WS, + id: WEBHOOK_ID, + url: URL, + conditions: [], + }) + + expect(mockUpdateWebhookCache).toHaveBeenCalledWith(WS) + }) + + test("audits only when the webhook row is found (if (result) gate)", async () => { + mockConditionFindMany.mockResolvedValue([]) + mockWebhookFindFirst.mockResolvedValue({ id: WEBHOOK_ID }) + + await webhookService.updateWithConditions({ + workspaceId: WS, + id: WEBHOOK_ID, + url: URL, + conditions: [], + }) + + expect(mockDispatchAuditRecord).toHaveBeenCalledWith({ + action: "update", + detail: `updated a webhook (#${WEBHOOK_ID})`, + }) + }) + + test("does not audit when the webhook row is not found", async () => { + mockConditionFindMany.mockResolvedValue([]) + mockWebhookFindFirst.mockResolvedValue(undefined) + + const result = await webhookService.updateWithConditions({ + workspaceId: WS, + id: WEBHOOK_ID, + url: URL, + conditions: [], + }) + + expect(result).toBeUndefined() + expect(mockDispatchAuditRecord).not.toHaveBeenCalled() + // Cache still refreshes unconditionally, unlike the audit. + expect(mockUpdateWebhookCache).toHaveBeenCalledWith(WS) + }) +}) diff --git a/packages/business/src/ai-agent/service.ts b/packages/business/src/ai-agent/service.ts index ea6f720146..ee2457b76b 100644 --- a/packages/business/src/ai-agent/service.ts +++ b/packages/business/src/ai-agent/service.ts @@ -242,7 +242,7 @@ class AiAgentService extends BaseService { workspaceId: string, data: CreateAIAgentRequest, tx?: DatabaseClient, - ): Promise { + ): Promise { const id = createId() const execute = async (client: DatabaseClient) => { @@ -270,6 +270,8 @@ class AiAgentService extends BaseService { if (!tx) { await this.audit("create", `created a new AI Agent (#${id})`) } + + return id } async updateAIAgent( diff --git a/packages/business/src/ai-trigger/service.ts b/packages/business/src/ai-trigger/service.ts index 3cbd52cd96..5b920f4f20 100644 --- a/packages/business/src/ai-trigger/service.ts +++ b/packages/business/src/ai-trigger/service.ts @@ -2,6 +2,7 @@ import { and, db, eq, inArray } from "@chatbotx.io/database/client" import { aiTriggerRepository } from "@chatbotx.io/database/repositories" import { aiTriggerModel } from "@chatbotx.io/database/schema" import type { AITriggerModel } from "@chatbotx.io/database/types" +import { maxLimit } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { notFoundException } from "../errors" @@ -27,7 +28,7 @@ class AITriggerService extends BaseService { aiTriggerRepository.count(input), ]) - const pageCount = Math.ceil(total / input.perPage) + const pageCount = Math.ceil(total / Math.min(maxLimit, input.perPage)) return { data, pageCount } } diff --git a/packages/business/src/automated-response/service.ts b/packages/business/src/automated-response/service.ts index f37dd6af7e..02204324f7 100644 --- a/packages/business/src/automated-response/service.ts +++ b/packages/business/src/automated-response/service.ts @@ -224,14 +224,42 @@ class AutomatedResponseService extends BaseService { where: { id: ctx.id, workspaceId: ctx.workspaceId }, columns: { folderId: true, keywords: true, text: true, flowId: true }, }) - const nextKeywords = data.keywords?.map((m) => m.value) ?? [] + + // `text` and `flowId` are mutually exclusive: setting one nulls the + // other, mirroring `create`'s behavior. Validate `flowId` belongs to + // this workspace before persisting it. + let nextFlowId = data.flowId + let nextText = data.text + if (data.text?.length) { + nextFlowId = null + } else if (data.flowId) { + const flowExists = await flowService.exists( + ctx.workspaceId, + data.flowId, + tx, + ) + if (!flowExists) { + throw validationException("flowId", "Flow not found") + } + nextText = null + } + + const { keywords: _keywords, ...restData } = data + const updatePayload: Partial = { + ...restData, + text: nextText, + flowId: nextFlowId, + } + // Only touch the `keywords` column when the caller actually supplied a + // value — omitting it must never wipe existing keywords. + const nextKeywords = data.keywords?.map((m) => m.value) + if (nextKeywords !== undefined) { + updatePayload.keywords = nextKeywords + } const [updated] = await client .update(automatedResponseModel) - .set({ - ...data, - keywords: nextKeywords, - }) + .set(updatePayload) .where( and( eq(automatedResponseModel.id, ctx.id), @@ -246,16 +274,17 @@ class AutomatedResponseService extends BaseService { } const keywordsChanged = - !existing || - nextKeywords.length !== existing.keywords.length || - nextKeywords.some( - (keyword, index) => keyword !== existing.keywords[index], - ) + nextKeywords !== undefined && + (!existing || + nextKeywords.length !== existing.keywords.length || + nextKeywords.some( + (keyword, index) => keyword !== existing.keywords[index], + )) const changed = !existing || (data.folderId !== undefined && data.folderId !== existing.folderId) || - (data.text !== undefined && data.text !== existing.text) || - (data.flowId !== undefined && data.flowId !== existing.flowId) || + (nextText !== undefined && nextText !== existing.text) || + (nextFlowId !== undefined && nextFlowId !== existing.flowId) || keywordsChanged if (!tx && changed) { diff --git a/packages/business/src/bot-field/service.ts b/packages/business/src/bot-field/service.ts index df9b7bb7af..24ea4384be 100644 --- a/packages/business/src/bot-field/service.ts +++ b/packages/business/src/bot-field/service.ts @@ -4,7 +4,7 @@ import { db, eq, inArray, - isDatabaseError, + isUniqueViolationError, relationsFilterToSQL, type SQL, sql, @@ -66,7 +66,6 @@ type CreateBotFieldData = { type UpdateBotFieldData = Partial const REGEX_BOT_FIELD_ID = /^\d+$/ -const UNIQUE_VIOLATION_CODE = "23505" /** * Which `CustomFieldType`s each `FieldOperationType` is valid against. @@ -473,10 +472,7 @@ class BotFieldService extends BaseService { .where(eq(botFieldModel.id, existing.id)) .returning() } catch (error) { - if ( - isDatabaseError(error) && - error.cause.code === UNIQUE_VIOLATION_CODE - ) { + if (isUniqueViolationError(error)) { throw validationException("name", "Name is already taken") } throw error @@ -515,10 +511,7 @@ class BotFieldService extends BaseService { .values({ id: createId(), workspaceId, ...preparedData }) .returning() } catch (error) { - if ( - isDatabaseError(error) && - error.cause.code === UNIQUE_VIOLATION_CODE - ) { + if (isUniqueViolationError(error)) { throw validationException("name", "Name is already taken") } throw error diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index 1ba1b20c88..7b739db468 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -1141,22 +1141,28 @@ class BroadcastService extends BaseService { await this.assertBroadcastIntegrationsOwned({ workspaceId, integrationMessengerId: rest.integrationMessengerId, - }).catch(() => { - throw validationException( - "integrationMessengerId", - "Integration not found", - ) + }).catch((error: unknown) => { + if (error instanceof ChatbotXException) { + throw validationException( + "integrationMessengerId", + "Integration not found", + ) + } + throw error }) } if (rest.integrationWhatsappId) { await this.assertBroadcastIntegrationsOwned({ workspaceId, integrationWhatsappId: rest.integrationWhatsappId, - }).catch(() => { - throw validationException( - "integrationWhatsappId", - "Integration not found", - ) + }).catch((error: unknown) => { + if (error instanceof ChatbotXException) { + throw validationException( + "integrationWhatsappId", + "Integration not found", + ) + } + throw error }) } @@ -1165,8 +1171,11 @@ class BroadcastService extends BaseService { broadcastName = await this.requireFlowName( workspaceId, rest.flowId, - ).catch(() => { - throw validationException("flowId", "Flow not found") + ).catch((error: unknown) => { + if (error instanceof ChatbotXException) { + throw validationException("flowId", "Flow not found") + } + throw error }) } @@ -1225,10 +1234,15 @@ class BroadcastService extends BaseService { * The transaction wraps a single insert — kept verbatim rather than * simplified, to avoid any semantic argument about what belongs inside it. */ - async resend(input: { + /** + * Runs `resend`'s existence/status guards up front so the caller can + * safely read `contactFilter` for pruning before the resend write — a + * foreign or soft-deleted id, or a broadcast that isn't sent/failed, + * throws here instead of the caller processing a row it shouldn't see. + */ + async assertResendable(input: { workspaceId: string id: string - contactFilter?: ContactFilterCriteriaInput | null }): Promise { const broadcast = await findOrFail({ table: broadcastModel, @@ -1241,6 +1255,15 @@ class BroadcastService extends BaseService { if (broadcast.status !== "sent" && broadcast.status !== "failed") { throw new ChatbotXException("Broadcast is not sent") } + return broadcast + } + + async resend(input: { + workspaceId: string + id: string + contactFilter?: ContactFilterCriteriaInput | null + }): Promise { + const broadcast = await this.assertResendable(input) const newBroadcast = await db.transaction(async (tx) => { const inserted = await tx diff --git a/packages/business/src/import/service.ts b/packages/business/src/import/service.ts index c787a2027f..3b82763450 100644 --- a/packages/business/src/import/service.ts +++ b/packages/business/src/import/service.ts @@ -267,7 +267,7 @@ class ImportService extends BaseService { */ async startFlowImport(input: { workspaceId: string - userId: string + userId: string | null fileId: string folderId?: string | null }): Promise< diff --git a/packages/business/src/reflink/service.ts b/packages/business/src/reflink/service.ts index d44155f49e..594f4b996a 100644 --- a/packages/business/src/reflink/service.ts +++ b/packages/business/src/reflink/service.ts @@ -57,6 +57,13 @@ class ReflinkService extends BaseService { return reflink } + async find(input: { + workspaceId: string + id: string + }): Promise { + return (await reflinkRepository.findByIdAndWorkspace(input)) ?? null + } + async create(input: { workspaceId: string data: ReflinkCreateData @@ -90,7 +97,12 @@ class ReflinkService extends BaseService { const [updated] = await db .update(reflinkModel) .set(data) - .where(and(eq(reflinkModel.id, reflink.id))) + .where( + and( + eq(reflinkModel.id, reflink.id), + eq(reflinkModel.workspaceId, ctx.workspaceId), + ), + ) .returning() return updated } catch (error) { diff --git a/packages/business/src/sequence/service.ts b/packages/business/src/sequence/service.ts index b882ea3be4..77aec57a69 100644 --- a/packages/business/src/sequence/service.ts +++ b/packages/business/src/sequence/service.ts @@ -3,7 +3,7 @@ import { db, eq, findOrFail, - isDatabaseError, + isUniqueViolationError, } from "@chatbotx.io/database/client" import { sequenceModel, sequenceStepModel } from "@chatbotx.io/database/schema" import type { @@ -12,15 +12,13 @@ import type { } from "@chatbotx.io/database/types" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" -import { validationException } from "../errors" +import { notFoundException, validationException } from "../errors" import { buildCreateData, buildUpdateData, type SequenceStepPayloadInput, } from "./step-payload" -const UNIQUE_VIOLATION_CODE = "23505" - class SequenceService extends BaseService { async create(input: { workspaceId: string @@ -37,10 +35,7 @@ class SequenceService extends BaseService { folderId: input.folderId || null, }) } catch (error) { - if ( - isDatabaseError(error) && - error.cause.code === UNIQUE_VIOLATION_CODE - ) { + if (isUniqueViolationError(error)) { throw validationException("name", "Name is already taken.") } throw error @@ -82,17 +77,19 @@ class SequenceService extends BaseService { const updated = await db .update(sequenceModel) .set(data) - .where(and(eq(sequenceModel.id, ctx.id))) + .where( + and( + eq(sequenceModel.id, ctx.id), + eq(sequenceModel.workspaceId, ctx.workspaceId), + ), + ) .returning({ id: sequenceModel.id }) if (updated.length === 0) { return } } catch (error) { - if ( - isDatabaseError(error) && - error.cause.code === UNIQUE_VIOLATION_CODE - ) { + if (isUniqueViolationError(error)) { throw validationException("name", "Name is already taken.") } throw error @@ -174,11 +171,11 @@ class SequenceService extends BaseService { }) if (!step) { - throw new Error("Step not found") + throw notFoundException("Step not found") } if (step.sequence.workspaceId !== input.workspaceId) { - throw new Error("Unauthorized: Step does not belong to this workspace") + throw notFoundException("Step not found") } const updateData = buildUpdateData(input.data) @@ -206,11 +203,11 @@ class SequenceService extends BaseService { }) if (!step) { - throw new Error("Step not found") + throw notFoundException("Step not found") } if (step.sequence.workspaceId !== input.workspaceId) { - throw new Error("Unauthorized: Step does not belong to this workspace") + throw notFoundException("Step not found") } await db diff --git a/packages/business/src/sequence/step-payload.ts b/packages/business/src/sequence/step-payload.ts index 5d27971be2..eb245274b5 100644 --- a/packages/business/src/sequence/step-payload.ts +++ b/packages/business/src/sequence/step-payload.ts @@ -1,5 +1,17 @@ import type { sequenceStepModel } from "@chatbotx.io/database/schema" +// The `delayUnit` column is a bare `text()` with no DB-level enum — this is +// the canonical union both the builder schema (`schema/action.ts`) and this +// write-boundary conform to, so the two can't drift apart. +export const SEQUENCE_STEP_DELAY_UNITS = [ + "immediate", + "minutes", + "hours", + "days", + "specificTime", +] as const +export type SequenceStepDelayUnit = (typeof SEQUENCE_STEP_DELAY_UNITS)[number] + /** * The subset of `upsertSequenceStepRequest` fields relevant to a step's * create/update payload — kept loose (`Partial`-friendly, all optional @@ -10,7 +22,7 @@ export type SequenceStepPayloadInput = { order: number delayDays?: number delayMinutes?: number - delayUnit?: string + delayUnit?: SequenceStepDelayUnit flowId?: string | null specificDateTime?: string | null isActive?: boolean diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index 78f313128c..6aa301ead2 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -1,5 +1,6 @@ import { and, db, eq, inArray } 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 { removeTriggerCache, updateTriggerCache } from "@chatbotx.io/events" @@ -71,6 +72,35 @@ class TriggerService extends BaseService { .where(eq(triggerModel.workspaceId, workspaceId)) } + /** + * SQL-paginated triggers with their real `conditions` joined in — for the + * public API's `GET /v1/triggers`, which previously loaded every trigger + * in the workspace and re-queried each one individually. + */ + async list(input: { + workspaceId: string + page: number + perPage: number + }): Promise<{ + data: (TriggerModel & { + conditions: (typeof conditionModel.$inferSelect)[] + })[] + pageCount: number + }> { + const { rows, total } = await triggerRepository.listPaginatedWithConditions( + { + workspaceId: input.workspaceId, + limit: input.perPage, + offset: (input.page - 1) * input.perPage, + }, + ) + + return { + data: rows, + pageCount: Math.max(1, Math.ceil(total / input.perPage)), + } + } + async deleteMany(input: { workspaceId: string ids: string[] diff --git a/packages/database/__tests__/template-selectable-resource-repository.test.ts b/packages/database/__tests__/template-selectable-resource-repository.test.ts index 8d7dfaad9e..8a52704684 100644 --- a/packages/database/__tests__/template-selectable-resource-repository.test.ts +++ b/packages/database/__tests__/template-selectable-resource-repository.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => ({ flowFindMany: vi.fn(), + tagFindMany: vi.fn(), automatedResponseFindMany: vi.fn(), savedReplyFindMany: vi.fn(), botFieldFindMany: vi.fn(), @@ -14,7 +15,7 @@ vi.mock("@chatbotx.io/database/client", () => ({ db: { query: { flowModel: { findMany: mocks.flowFindMany }, - tagModel: { findMany: vi.fn() }, + tagModel: { findMany: mocks.tagFindMany }, customFieldModel: { findMany: vi.fn() }, productModel: { findMany: vi.fn() }, aiFunctionModel: { findMany: vi.fn() }, @@ -96,6 +97,34 @@ describe("templateSelectableResourceRepository.listFlows", () => { }) }) +describe("templateSelectableResourceRepository.listTags", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("filters out soft-deleted tags via deletedAt isNull", async () => { + mocks.tagFindMany + .mockResolvedValueOnce([{ id: "tag-1", name: "Tag 1" }]) + .mockResolvedValueOnce([{ id: "tag-1" }]) + mocks.count.mockResolvedValue(1) + + const result = await templateSelectableResourceRepository.listTags({ + workspaceId: "ws-1", + offset: 0, + limit: 100, + }) + + expect(result.rows).toEqual([{ id: "tag-1", name: "Tag 1" }]) + expect(mocks.tagFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + deletedAt: { isNull: true }, + }), + }), + ) + }) +}) + describe("templateSelectableResourceRepository.listKeywords", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/database/src/repositories/broadcast/repository.ts b/packages/database/src/repositories/broadcast/repository.ts index e0229375e0..036eee86e4 100644 --- a/packages/database/src/repositories/broadcast/repository.ts +++ b/packages/database/src/repositories/broadcast/repository.ts @@ -143,7 +143,11 @@ export const broadcastRepository = { tx: DatabaseClient = db, ): Promise<{ contactFilter: unknown } | undefined> { return await tx.query.broadcastModel.findFirst({ - where: { id: input.id, workspaceId: input.workspaceId }, + where: { + id: input.id, + workspaceId: input.workspaceId, + deletedAt: { isNull: true }, + }, columns: { contactFilter: true }, }) }, diff --git a/packages/database/src/repositories/template-selectable-resource/repository.ts b/packages/database/src/repositories/template-selectable-resource/repository.ts index 4399f9036e..a7234f2ee7 100644 --- a/packages/database/src/repositories/template-selectable-resource/repository.ts +++ b/packages/database/src/repositories/template-selectable-resource/repository.ts @@ -1,3 +1,4 @@ +import type { PgTable } from "drizzle-orm/pg-core" import { type DatabaseClient, db, relationsFilterToSQL } from "../../client" import { aiAgentModel, @@ -42,365 +43,108 @@ type CategoryInput = { limit: number } -export const templateSelectableResourceRepository = { - async listFlows( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.flowModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(flowModel, relationsFilterToSQL(flowModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - (await tx.query.flowModel.findMany({ where, columns: { id: true } })).map( - (row) => row.id, - ), - ) - - return { rows, total, allIds } +/** + * Shared shape behind 11 of the 12 `list*` categories below: a + * `{id, name}`-selectable table filtered by `workspaceId` (+ optional + * `deletedAt`/extra predicate) and an ILIKE `name` search, paginated with a + * capped `allIds` "select all" list. `listKeywords` (no `name` column) and + * `listSettings` (two tables, no search/pagination) are genuine special + * cases and stay hand-written below. + */ +function findAllQuery( + tableQuery: { + findMany: (args: { + where: Record + columns: { id: true; name: true } + limit: number + offset: number + orderBy: { name: "asc" } + }) => Promise<{ id: string; name: string }[]> }, - - async listTags( + table: TTable, + extraWhere?: Record, +) { + return async ( input: CategoryInput, tx: DatabaseClient = db, - ): Promise { + ): Promise => { const { workspaceId, keyword, offset, limit } = input const where = { workspaceId, - deletedAt: { isNull: true as const }, + ...extraWhere, name: keyword ? { ilike: likeContains(keyword) } : undefined, } const [rows, total] = await Promise.all([ - tx.query.tagModel.findMany({ + tableQuery.findMany({ where, columns: { id: true, name: true }, limit, offset, orderBy: { name: "asc" }, }), - tx.$count(tagModel, relationsFilterToSQL(tagModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - (await tx.query.tagModel.findMany({ where, columns: { id: true } })).map( - (row) => row.id, - ), - ) - - return { rows, total, allIds } - }, - - async listCustomFields( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.customFieldModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count( - customFieldModel, - relationsFilterToSQL(customFieldModel, where), - ), + tx.$count(table, relationsFilterToSQL(table, where)), ]) const allIds = await buildAllIds(offset, total, async () => ( - await tx.query.customFieldModel.findMany({ + await tableQuery.findMany({ where, - columns: { id: true }, + columns: { id: true, name: true }, + limit: ALL_IDS_CAP, + offset: 0, + orderBy: { name: "asc" }, }) ).map((row) => row.id), ) return { rows, total, allIds } - }, - - async listProducts( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.productModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(productModel, relationsFilterToSQL(productModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.productModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { rows, total, allIds } - }, - - async listAIFunctions( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.aiFunctionModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(aiFunctionModel, relationsFilterToSQL(aiFunctionModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.aiFunctionModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { rows, total, allIds } - }, - - async listAIAgents( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.aiAgentModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(aiAgentModel, relationsFilterToSQL(aiAgentModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.aiAgentModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) + } +} - return { rows, total, allIds } - }, +export const templateSelectableResourceRepository = { + listFlows: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.flowModel, flowModel)(input, tx), - async listCalendars( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, + listTags: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.tagModel, tagModel, { deletedAt: { isNull: true as const }, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } + })(input, tx), - const [rows, total] = await Promise.all([ - tx.query.appointmentCalendarModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count( - appointmentCalendarModel, - relationsFilterToSQL(appointmentCalendarModel, where), - ), - ]) + listCustomFields: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.customFieldModel, customFieldModel)(input, tx), - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.appointmentCalendarModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) + listProducts: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.productModel, productModel)(input, tx), - return { rows, total, allIds } - }, + listAIFunctions: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.aiFunctionModel, aiFunctionModel)(input, tx), - async listWebchats( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.integrationWebchatModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count( - integrationWebchatModel, - relationsFilterToSQL(integrationWebchatModel, where), - ), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.integrationWebchatModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) + listAIAgents: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.aiAgentModel, aiAgentModel)(input, tx), - return { rows, total, allIds } - }, - - async listTriggers( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.triggerModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(triggerModel, relationsFilterToSQL(triggerModel, where)), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.triggerModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) - - return { rows, total, allIds } - }, - - async listFbCommentAutomations( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } - - const [rows, total] = await Promise.all([ - tx.query.fbCommentAutomationModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count( - fbCommentAutomationModel, - relationsFilterToSQL(fbCommentAutomationModel, where), - ), - ]) - - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.fbCommentAutomationModel.findMany({ - where, - columns: { id: true }, - }) - ).map((row) => row.id), - ) - - return { rows, total, allIds } - }, + listCalendars: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.appointmentCalendarModel, appointmentCalendarModel, { + deletedAt: { isNull: true as const }, + })(input, tx), - async listEntryPointLinks( - input: CategoryInput, - tx: DatabaseClient = db, - ): Promise { - const { workspaceId, keyword, offset, limit } = input - const where = { - workspaceId, - name: keyword ? { ilike: likeContains(keyword) } : undefined, - } + listWebchats: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.integrationWebchatModel, integrationWebchatModel)( + input, + tx, + ), - const [rows, total] = await Promise.all([ - tx.query.reflinkModel.findMany({ - where, - columns: { id: true, name: true }, - limit, - offset, - orderBy: { name: "asc" }, - }), - tx.$count(reflinkModel, relationsFilterToSQL(reflinkModel, where)), - ]) + listTriggers: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.triggerModel, triggerModel)(input, tx), - const allIds = await buildAllIds(offset, total, async () => - ( - await tx.query.reflinkModel.findMany({ where, columns: { id: true } }) - ).map((row) => row.id), - ) + listFbCommentAutomations: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.fbCommentAutomationModel, fbCommentAutomationModel)( + input, + tx, + ), - return { rows, total, allIds } - }, + listEntryPointLinks: (input: CategoryInput, tx: DatabaseClient = db) => + findAllQuery(tx.query.reflinkModel, reflinkModel)(input, tx), /** * `AutomatedResponse` (Keywords) has no `name` column — inbound rows are @@ -456,6 +200,7 @@ export const templateSelectableResourceRepository = { await tx.query.automatedResponseModel.findMany({ where, columns: { id: true }, + limit: ALL_IDS_CAP, }) ).map((row) => row.id), ) diff --git a/packages/database/src/repositories/trigger/repository.ts b/packages/database/src/repositories/trigger/repository.ts index 551f0279f7..dd1fb839d7 100644 --- a/packages/database/src/repositories/trigger/repository.ts +++ b/packages/database/src/repositories/trigger/repository.ts @@ -56,6 +56,34 @@ export const triggerRepository = { return { rows, total: countResult[0]?.count ?? 0 } }, + /** + * Paginated trigger rows with their real `conditions` joined in, SQL-level + * — for the public API's `GET /v1/triggers`, which needs the same shape + * as `findWithConditions` but for a page of rows instead of one. + */ + async listPaginatedWithConditions( + input: { + workspaceId: string + limit: number + offset: number + }, + tx: DatabaseClient = db, + ) { + const whereClause = eq(triggerModel.workspaceId, input.workspaceId) + + const [rows, total] = await Promise.all([ + tx.query.triggerModel.findMany({ + where: { workspaceId: input.workspaceId }, + with: { conditions: true }, + limit: input.limit, + offset: input.offset, + }), + tx.$count(triggerModel, whereClause), + ]) + + return { rows, total } + }, + async findWithConditions( params: { id?: string; workspaceId?: string }, tx: DatabaseClient = db, From ecf971ea9eb060686865174faf9f76c1d1eb1db6 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 8 Sep 2026 21:01:40 +0700 Subject: [PATCH 4/8] fix(automation): restore flows list contract and field-level validation errors - GET /v1/flows again defaults `active: true` and returns `{id, name}` instead of the full flow row plus embedded flowVersions, avoiding a breaking change to an existing public-API consumer contract. - Add isValidationException (a real instanceof ChatbotXException guard) and use it in place of duck-typed `"code" in error` checks in reflinks, bot-fields, and sequences actions, which could mis-narrow on unrelated driver/system errors and always hardcoded the error message. - Wrap automatedResponseService.update in update-automated-response-action so the flowId-not-found validation exception (moved into the service by the prior refactor) still surfaces as a field-level form error instead of a generic toast. - Fix aiTriggerService.list to divide pageCount by the same clamped limit the repository uses (getPaginationWithDefaults), instead of a Math.min(maxLimit, perPage) expression that could divide by undefined. --- .../__tests__/create-sequence.action.test.ts | 9 +++--- .../__tests__/flows-public-api.test.ts | 25 ++++++++++++++++ .../update-automated-response-action.ts | 23 +++++++++++--- .../actions/create-bot-field.action.ts | 9 ++---- .../actions/update-bot-field.action.ts | 9 ++---- .../actions/resend-broadcast.action.ts | 5 +++- apps/builder/src/features/flows/api/public.ts | 30 +++++++++++-------- .../reflinks/actions/create-reflink.action.ts | 9 ++---- .../reflinks/actions/update-reflink.action.ts | 9 ++---- .../actions/create-sequence.action.ts | 7 ++--- .../actions/update-sequence.action.ts | 7 ++--- .../src/lib/errors/validation-exception.ts | 15 ++++++++++ packages/business/src/ai-trigger/service.ts | 5 ++-- 13 files changed, 105 insertions(+), 57 deletions(-) create mode 100644 apps/builder/src/lib/errors/validation-exception.ts diff --git a/apps/builder/__tests__/create-sequence.action.test.ts b/apps/builder/__tests__/create-sequence.action.test.ts index e6b1d7655d..d61d56f7f5 100644 --- a/apps/builder/__tests__/create-sequence.action.test.ts +++ b/apps/builder/__tests__/create-sequence.action.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node +import { validationException } from "@chatbotx.io/business/errors" import { beforeEach, describe, expect, test, vi } from "vitest" const { mockCreate, mockReturnValidationErrors, mockGetTranslations } = @@ -77,10 +78,10 @@ describe("createSequenceAction", () => { }) test("maps a validationException(name) to returnValidationErrors with the createSequenceRequest schema", async () => { - const validationError = Object.assign(new Error("Name is already taken."), { - code: "validation", - field: "name", - }) + const validationError = validationException( + "name", + "Name is already taken.", + ) mockCreate.mockRejectedValue(validationError) const result = await callAction({ diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index fbacc04f28..bbeec04ddd 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -133,6 +133,31 @@ describe("GET /v1/flows", () => { workspaceId: "workspace-1", }) }) + + test("returns only id and name per flow, not the full resource", async () => { + flowService.list.mockResolvedValueOnce({ + data: [ + { + id: "flow-1", + name: "Flow 1", + workspaceId: "workspace-1", + active: true, + flowVersions: [{ id: "version-1" }], + }, + ], + pageCount: 1, + }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, active: true }, + }) + + expect(result).toEqual({ + data: [{ id: "flow-1", name: "Flow 1" }], + pageCount: 1, + }) + }) }) describe("GET /v1/flows/{id}", () => { diff --git a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts index c727bee127..0003cc204d 100644 --- a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts +++ b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts @@ -5,6 +5,8 @@ import { type UpdateAutomatedResponseRequest, } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" +import { returnValidationErrors } from "next-safe-action" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { updateAutomatedResponseRequest } from "../schema/action" @@ -29,8 +31,21 @@ export const updateAutomatedResponse = async ( id: ctx.id, }) - // `text`/`flowId` mutual-exclusion and cross-workspace `flowId` - // validation now live in `automatedResponseService.update` so every - // caller (this action and the public API) gets the same invariants. - await automatedResponseService.update(ctx, parsedInput) + try { + // `text`/`flowId` mutual-exclusion and cross-workspace `flowId` + // validation live in `automatedResponseService.update` so every caller + // (this action and the public API) gets the same invariants — caught + // here so the form still sees a field-level error instead of a + // generic toast. + await automatedResponseService.update(ctx, parsedInput) + } catch (error) { + if (isValidationException(error)) { + return returnValidationErrors(updateAutomatedResponseRequest, { + _errors: ["Validation Exception"], + flowId: { _errors: [error.message] }, + }) + } + + throw error + } } diff --git a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts index 943745045f..1cce5493b2 100644 --- a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts +++ b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts @@ -3,6 +3,7 @@ import { botFieldService } from "@chatbotx.io/business" import { returnValidationErrors } from "next-safe-action" import { workspaceIdrequestParams } from "@/features/common/schema" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { createBotFieldRequest } from "../schema/action" @@ -20,14 +21,10 @@ export const createBotFieldAction = workspaceActionClient } catch (error) { // Unique (workspaceId, type, name) — surface a field-level error under // Name instead of the generic toast (mirrors createCustomFieldAction). - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(createBotFieldRequest, { _errors: ["Validation Exception"], - name: { _errors: ["Name is already taken"] }, + name: { _errors: [error.message] }, }) } throw error diff --git a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts index f0b470ee40..cf0dce33c5 100644 --- a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts +++ b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts @@ -6,6 +6,7 @@ import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, } from "@/features/common/schema" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { type UpdateBotFieldRequest, @@ -32,14 +33,10 @@ export const updateBotFieldAction = workspaceActionClient } catch (error) { // Renaming into an existing (type, name) hits the same unique index // as create — surface it under the Name field, not a generic toast. - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(updateBotFieldRequest, { _errors: ["Validation Exception"], - name: { _errors: ["Name is already taken"] }, + name: { _errors: [error.message] }, }) } throw error diff --git a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts index a19c2529d2..382df8bcc8 100644 --- a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts @@ -26,7 +26,10 @@ export const resendBroadcast = async (ctx: { // Verify the broadcast exists (not soft-deleted, in-workspace) and is in // a resendable status before reading its `contactFilter` — main checked // existence first; reading before the guard would let a foreign or - // deleted id be processed. + // deleted id be processed. `broadcastService.resend` re-asserts this + // itself before its own insert, so the check is intentionally duplicated + // rather than redundant: this pre-check exists to guard the + // `contactFilter` read below, not the resend itself. await broadcastService.assertResendable({ workspaceId: ctx.workspaceId, id: ctx.id, diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index 70983b76f8..a82fc706dd 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -23,7 +23,7 @@ import { updateDraftFlowVersionSchema, updateFlowSchema, } from "../schema/action" -import { flowWithVersionsResource } from "../schema/resource" +import { flowResource, flowWithVersionsResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("automation") @@ -33,20 +33,26 @@ export const flowsPublicRouter = { method: "GET", path: "/v1/flows", summary: "List flows", - description: - "Lists flows in the workspace. Omit `active` to return both active and inactive flows.", + description: "Lists active flows in the workspace.", tags: ["Flows"], }) - .input(publicListRequest.extend({ active: z.boolean().optional() })) - .output(publicListResponse(flowWithVersionsResource)) + .input( + publicListRequest.extend({ + active: z.boolean().optional().default(true), + }), + ) + .output(publicListResponse(flowResource.pick({ id: true, name: true }))) .errors(possibleErrorsOnListingResource) - .handler( - async ({ context, input }) => - await flowService.list({ - ...input, - workspaceId: context.workspace.id, - }), - ), + .handler(async ({ context, input }) => { + const { data, pageCount } = await flowService.list({ + ...input, + workspaceId: context.workspace.id, + }) + return { + data: data.map((flow) => ({ id: flow.id, name: flow.name })), + pageCount, + } + }), get: workspaceTokenAuthAPI .route({ diff --git a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts index b498fcc3c9..ab459503a5 100644 --- a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts +++ b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts @@ -6,6 +6,7 @@ import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { type CreateReflinkRequest, @@ -26,14 +27,10 @@ export const createReflinkAction = workspaceActionClient try { await reflinkService.create({ workspaceId, data: parsedInput }) } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(createReflinkRequest, { _errors: ["Validation Exception"], - name: { _errors: ["Name is already taken"] }, + name: { _errors: [error.message] }, }) } diff --git a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts index 84bb9a8d80..6d9d519b5c 100644 --- a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts +++ b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts @@ -3,6 +3,7 @@ import { reflinkService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { returnValidationErrors } from "next-safe-action" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { updateReflinkRequest } from "../schema/action" @@ -18,14 +19,10 @@ export const updateReflinkAction = workspaceActionClient try { await reflinkService.update({ workspaceId, id }, parsedInput) } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(updateReflinkRequest, { _errors: ["Validation Exception"], - name: { _errors: ["Name is already taken"] }, + name: { _errors: [error.message] }, }) } diff --git a/apps/builder/src/features/sequences/actions/create-sequence.action.ts b/apps/builder/src/features/sequences/actions/create-sequence.action.ts index f724485ed4..59b7de54db 100644 --- a/apps/builder/src/features/sequences/actions/create-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/create-sequence.action.ts @@ -7,6 +7,7 @@ import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { type CreateSequenceRequest, @@ -33,11 +34,7 @@ export const createSequenceAction = workspaceActionClient folderId: parsedInput.folderId, }) } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(createSequenceRequest, { _errors: [t("sequences.validation.exception")], name: { diff --git a/apps/builder/src/features/sequences/actions/update-sequence.action.ts b/apps/builder/src/features/sequences/actions/update-sequence.action.ts index dd58442429..ca377f5212 100644 --- a/apps/builder/src/features/sequences/actions/update-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/update-sequence.action.ts @@ -5,6 +5,7 @@ import { ChatbotXException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { updateSequenceSchema } from "../schema/action" @@ -22,11 +23,7 @@ export const updateSequenceAction = workspaceActionClient try { await sequenceService.update({ workspaceId, id }, parsedInput) } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "validation" - ) { + if (isValidationException(error)) { return returnValidationErrors(updateSequenceSchema, { _errors: [t("sequences.validation.exception")], name: { diff --git a/apps/builder/src/lib/errors/validation-exception.ts b/apps/builder/src/lib/errors/validation-exception.ts new file mode 100644 index 0000000000..22c438e407 --- /dev/null +++ b/apps/builder/src/lib/errors/validation-exception.ts @@ -0,0 +1,15 @@ +import { ChatbotXException } from "@chatbotx.io/business/errors" + +/** + * Narrows a caught error to a service-thrown, field-scoped validation + * failure (`validationException` in `packages/business/src/errors.ts`). + * Prefer this over a duck-typed `"code" in error` check — a driver or + * runtime error can carry an unrelated `code` property, and only a real + * `ChatbotXException` is guaranteed to carry `field`/`message` safe to show + * a user. + */ +export function isValidationException( + error: unknown, +): error is ChatbotXException & { code: "validation" } { + return error instanceof ChatbotXException && error.code === "validation" +} diff --git a/packages/business/src/ai-trigger/service.ts b/packages/business/src/ai-trigger/service.ts index 5b920f4f20..64572ef352 100644 --- a/packages/business/src/ai-trigger/service.ts +++ b/packages/business/src/ai-trigger/service.ts @@ -2,7 +2,7 @@ import { and, db, eq, inArray } from "@chatbotx.io/database/client" import { aiTriggerRepository } from "@chatbotx.io/database/repositories" import { aiTriggerModel } from "@chatbotx.io/database/schema" import type { AITriggerModel } from "@chatbotx.io/database/types" -import { maxLimit } from "@chatbotx.io/database/utils" +import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { notFoundException } from "../errors" @@ -28,7 +28,8 @@ class AITriggerService extends BaseService { aiTriggerRepository.count(input), ]) - const pageCount = Math.ceil(total / Math.min(maxLimit, input.perPage)) + const { limit } = getPaginationWithDefaults(input) + const pageCount = Math.ceil(total / limit) return { data, pageCount } } From 745719544a12305b3c36fca46947ddb134b70df2 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 06:34:11 +0700 Subject: [PATCH 5/8] fix(automation): scope keyword writes by type and stabilize public API pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third round of correctness fixes on the public API surface: - Keywords endpoints now pass `type: "inbound"` through findOrFail, update, setStatus and deleteMany so an outbound (Page) automated response can no longer be read, mutated or deleted through the inbound Keywords routes — one table serves two FolderTypes, so workspaceId + id alone is not a sufficient scope. - Add `flowVersionService.updateDraftByFlowId` for callers that only know the flow id; `PUT /v1/flows/{id}/draft` was passing a flow id where a flow-version id was expected. - Give `triggerRepository.listPaginatedWithConditions` a deterministic `orderBy` so paginated results cannot repeat or skip rows. - Skip no-op updates in ai-agent, ai-trigger and reflink services so an all-undefined payload no longer issues an empty SET or a spurious audit entry. - Write `questions: []` explicitly on ai-trigger create — the column has no database default despite the drizzle `.default()`. - Correct `flowService.list`'s return type to `limit`/`offset`, matching what `parsePagination` actually spreads. --- .../__tests__/flows-public-api.test.ts | 10 +++---- .../__tests__/keywords-public-api.test.ts | 8 +++-- .../features/automated-response/api/public.ts | 16 ++++++---- apps/builder/src/features/flows/api/public.ts | 4 +-- docs/developer/workspace-api-tokens.md | 4 +-- packages/business/src/ai-agent/service.ts | 5 ++++ packages/business/src/ai-trigger/service.ts | 6 ++++ .../src/automated-response/service.ts | 22 +++++++++++--- packages/business/src/flow-version/service.ts | 29 +++++++++++++++++++ packages/business/src/flow/service.ts | 4 +-- packages/business/src/reflink/service.ts | 5 ++++ packages/business/src/trigger/service.ts | 7 ----- .../src/repositories/trigger/repository.ts | 1 + 13 files changed, 92 insertions(+), 29 deletions(-) diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index bbeec04ddd..c60b0c9901 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -56,7 +56,7 @@ const flowService = { } const flowVersionService = { publish: vi.fn(), - updateDraft: vi.fn(), + updateDraftByFlowId: vi.fn(), list: vi.fn(), } const importService = { @@ -285,17 +285,17 @@ describe("POST /v1/flows/{id}/publish", () => { describe("PUT /v1/flows/{id}/draft", () => { const procedure = findProcedure("PUT", "/v1/flows/{id}/draft") - test("delegates to flowVersionService.updateDraft", async () => { - flowVersionService.updateDraft.mockResolvedValueOnce(undefined) + test("delegates to flowVersionService.updateDraftByFlowId", async () => { + flowVersionService.updateDraftByFlowId.mockResolvedValueOnce(undefined) await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, input: { id: "flow-1", nodes: [], edges: [] }, }) - expect(flowVersionService.updateDraft).toHaveBeenCalledWith({ + expect(flowVersionService.updateDraftByFlowId).toHaveBeenCalledWith({ workspaceId: "workspace-1", - id: "flow-1", + flowId: "flow-1", nodes: [], edges: [], }) diff --git a/apps/builder/__tests__/keywords-public-api.test.ts b/apps/builder/__tests__/keywords-public-api.test.ts index 39638acdad..cfb4e013d7 100644 --- a/apps/builder/__tests__/keywords-public-api.test.ts +++ b/apps/builder/__tests__/keywords-public-api.test.ts @@ -148,6 +148,7 @@ describe("GET /v1/keywords/{id}", () => { expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ workspaceId: "workspace-1", id: "keyword-1", + type: "inbound", }) }) }) @@ -191,9 +192,10 @@ describe("PUT /v1/keywords/{id}", () => { expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ workspaceId: "workspace-1", id: "keyword-1", + type: "inbound", }) expect(automatedResponseService.update).toHaveBeenCalledWith( - { workspaceId: "workspace-1", id: "keyword-1" }, + { workspaceId: "workspace-1", id: "keyword-1", type: "inbound" }, { keywords: [{ value: "hello" }] }, ) }) @@ -216,7 +218,7 @@ describe("PATCH /v1/keywords/{id}/status", () => { }) expect(automatedResponseService.setStatus).toHaveBeenCalledWith( - { workspaceId: "workspace-1", id: "keyword-1" }, + { workspaceId: "workspace-1", id: "keyword-1", type: "inbound" }, false, ) }) @@ -236,6 +238,8 @@ describe("DELETE /v1/keywords/{id}", () => { expect(automatedResponseService.deleteMany).toHaveBeenCalledWith( "workspace-1", ["keyword-1"], + undefined, + "inbound", ) }) }) diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index b57e3be58d..6ec83b0ed6 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -57,6 +57,7 @@ export const keywordsPublicRouter = { await automatedResponseService.findOrFail({ workspaceId: context.workspace.id, id: input.id, + type: "inbound", }), ), @@ -107,9 +108,10 @@ export const keywordsPublicRouter = { await automatedResponseService.findOrFail({ workspaceId: context.workspace.id, id, + type: "inbound", }) return await automatedResponseService.update( - { workspaceId: context.workspace.id, id }, + { workspaceId: context.workspace.id, id, type: "inbound" }, { ...rest, keywords: keywords?.map((value) => ({ value })), @@ -131,9 +133,10 @@ export const keywordsPublicRouter = { await automatedResponseService.findOrFail({ workspaceId: context.workspace.id, id: input.id, + type: "inbound", }) return await automatedResponseService.setStatus( - { workspaceId: context.workspace.id, id: input.id }, + { workspaceId: context.workspace.id, id: input.id, type: "inbound" }, input.status, ) }), @@ -149,8 +152,11 @@ export const keywordsPublicRouter = { .input(z.object({ id: zodBigintAsString() })) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { - await automatedResponseService.deleteMany(context.workspace.id, [ - input.id, - ]) + await automatedResponseService.deleteMany( + context.workspace.id, + [input.id], + undefined, + "inbound", + ) }), } diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index a82fc706dd..ee77fc060c 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -183,9 +183,9 @@ export const flowsPublicRouter = { .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, nodes, edges } = input - await flowVersionService.updateDraft({ + await flowVersionService.updateDraftByFlowId({ workspaceId: context.workspace.id, - id, + flowId: id, nodes, edges, }) diff --git a/docs/developer/workspace-api-tokens.md b/docs/developer/workspace-api-tokens.md index 8545bbed90..78b3c616a8 100644 --- a/docs/developer/workspace-api-tokens.md +++ b/docs/developer/workspace-api-tokens.md @@ -151,10 +151,10 @@ method the corresponding UI action calls (`.agents/rules/data-access.md`). | Flows | `DELETE /v1/flows/{id}` | `flowService.deleteMany` | | Flows | `POST /v1/flows/{id}/duplicate` | `flowService.duplicate` | | Flows | `POST /v1/flows/{id}/publish` | `flowVersionService.publish` | -| Flows | `PUT /v1/flows/{id}/draft` | `flowVersionService.updateDraft` | +| Flows | `PUT /v1/flows/{id}/draft` | `flowVersionService.updateDraftByFlowId` | | Flows | `GET /v1/flows/{id}/versions` | `flowVersionService.list` | | Flows | `POST /v1/flows/import` | `importService.startFlowImport` | -| Triggers | `GET /v1/triggers` | `triggerService.listByWorkspaceId` + `triggerRepository.findWithConditions` | +| Triggers | `GET /v1/triggers` | `triggerRepository.listPaginatedWithConditions` | | Triggers | `GET /v1/triggers/{id}` | `triggerRepository.findWithConditions` | | Triggers | `POST /v1/triggers` | `triggerService.create` | | Triggers | `PUT /v1/triggers/{id}` | `triggerService.updateWithConditions` | diff --git a/packages/business/src/ai-agent/service.ts b/packages/business/src/ai-agent/service.ts index ee2457b76b..ac1c918f10 100644 --- a/packages/business/src/ai-agent/service.ts +++ b/packages/business/src/ai-agent/service.ts @@ -286,6 +286,11 @@ class AiAgentService extends BaseService { throw notFoundException("AI agent not found") } + const hasChanges = Object.values(data).some((value) => value !== undefined) + if (!hasChanges) { + return + } + await db.transaction(async (tx) => { if (data.isDefault) { await tx diff --git a/packages/business/src/ai-trigger/service.ts b/packages/business/src/ai-trigger/service.ts index 64572ef352..8342c0c3ad 100644 --- a/packages/business/src/ai-trigger/service.ts +++ b/packages/business/src/ai-trigger/service.ts @@ -54,6 +54,7 @@ class AITriggerService extends BaseService { .values({ id: createId(), workspaceId: input.workspaceId, + questions: [], ...input.data, }) .returning() @@ -69,6 +70,11 @@ class AITriggerService extends BaseService { ): Promise { const aiTrigger = await this.findOrFail(ctx) + const hasChanges = Object.values(data).some((value) => value !== undefined) + if (!hasChanges) { + return aiTrigger + } + const [updated] = await db .update(aiTriggerModel) .set(data) diff --git a/packages/business/src/automated-response/service.ts b/packages/business/src/automated-response/service.ts index 02204324f7..520840d090 100644 --- a/packages/business/src/automated-response/service.ts +++ b/packages/business/src/automated-response/service.ts @@ -38,6 +38,7 @@ export type UpdateAutomatedResponseRequest = { export type FindAutomatedResponseRequest = { workspaceId: string id: string + type?: AutomatedResponseType } export type ListAutomatedResponsesRequest = { @@ -60,6 +61,7 @@ class AutomatedResponseService extends BaseService { where: { workspaceId: input.workspaceId, id: input.id, + ...(input.type ? { type: input.type } : {}), }, }) } @@ -212,7 +214,7 @@ class AutomatedResponseService extends BaseService { } async update( - ctx: { id: string; workspaceId: string }, + ctx: { id: string; workspaceId: string; type?: AutomatedResponseType }, data: UpdateAutomatedResponseRequest, tx?: DatabaseClient, ): Promise { @@ -221,7 +223,11 @@ class AutomatedResponseService extends BaseService { // Fetched before the write so a Save that resubmits identical values // doesn't produce an "updated" audit entry. const existing = await client.query.automatedResponseModel.findFirst({ - where: { id: ctx.id, workspaceId: ctx.workspaceId }, + where: { + id: ctx.id, + workspaceId: ctx.workspaceId, + ...(ctx.type ? { type: ctx.type } : {}), + }, columns: { folderId: true, keywords: true, text: true, flowId: true }, }) @@ -264,6 +270,7 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.id, ctx.id), eq(automatedResponseModel.workspaceId, ctx.workspaceId), + ...(ctx.type ? [eq(automatedResponseModel.type, ctx.type)] : []), ), ) .returning() @@ -298,14 +305,18 @@ class AutomatedResponseService extends BaseService { } async setStatus( - ctx: { id: string; workspaceId: string }, + ctx: { id: string; workspaceId: string; type?: AutomatedResponseType }, status: boolean, tx?: DatabaseClient, ): Promise { const client = tx ?? db const existing = await client.query.automatedResponseModel.findFirst({ - where: { id: ctx.id, workspaceId: ctx.workspaceId }, + where: { + id: ctx.id, + workspaceId: ctx.workspaceId, + ...(ctx.type ? { type: ctx.type } : {}), + }, columns: { status: true }, }) @@ -316,6 +327,7 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.id, ctx.id), eq(automatedResponseModel.workspaceId, ctx.workspaceId), + ...(ctx.type ? [eq(automatedResponseModel.type, ctx.type)] : []), ), ) .returning() @@ -339,6 +351,7 @@ class AutomatedResponseService extends BaseService { workspaceId: string, ids: string[], tx?: DatabaseClient, + type?: AutomatedResponseType, ): Promise { await assertDeletable({ workspaceId, @@ -354,6 +367,7 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.workspaceId, workspaceId), inArray(automatedResponseModel.id, ids), + ...(type ? [eq(automatedResponseModel.type, type)] : []), ), ) .returning({ id: automatedResponseModel.id }) diff --git a/packages/business/src/flow-version/service.ts b/packages/business/src/flow-version/service.ts index df80ab5d56..61b88fa269 100644 --- a/packages/business/src/flow-version/service.ts +++ b/packages/business/src/flow-version/service.ts @@ -316,6 +316,35 @@ class FlowVersionService extends BaseService { .where(eq(flowVersionModel.id, flowVersion.id)) } + /** + * Same as `updateDraft`, but resolves the draft version from a flow id + * instead of a flow-version id — for callers (like the public API) that + * only know the flow. + */ + async updateDraftByFlowId(input: { + workspaceId: string + flowId: string + nodes: FlowVersionModel["nodes"] + edges: FlowVersionModel["edges"] + }): Promise { + const draftVersion = await this.findDraft({ + flowId: input.flowId, + workspaceId: input.workspaceId, + }) + + if (!draftVersion) { + throw notFoundException("Draft flow version not found") + } + + await db + .update(flowVersionModel) + .set({ + nodes: input.nodes, + edges: input.edges, + }) + .where(eq(flowVersionModel.id, draftVersion.id)) + } + async invalidateList(flowId: string): Promise { await this.invalidateCacheTags(`flows:${flowId}:versions`) } diff --git a/packages/business/src/flow/service.ts b/packages/business/src/flow/service.ts index c44d5c1b03..1836cee24f 100644 --- a/packages/business/src/flow/service.ts +++ b/packages/business/src/flow/service.ts @@ -108,8 +108,8 @@ class FlowService extends BaseService { ): Promise<{ data: Awaited> pageCount: number - page?: number - perPage?: number + limit?: number + offset?: number }> { const pagination = parsePagination(input) diff --git a/packages/business/src/reflink/service.ts b/packages/business/src/reflink/service.ts index 594f4b996a..9315c8ae41 100644 --- a/packages/business/src/reflink/service.ts +++ b/packages/business/src/reflink/service.ts @@ -93,6 +93,11 @@ class ReflinkService extends BaseService { ): Promise { const reflink = await this.findOrFail(ctx) + const hasChanges = Object.values(data).some((value) => value !== undefined) + if (!hasChanges) { + return reflink + } + try { const [updated] = await db .update(reflinkModel) diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index 6aa301ead2..36ec27556a 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -65,13 +65,6 @@ class TriggerService extends BaseService { return created } - async listByWorkspaceId(workspaceId: string): Promise { - return await db - .select() - .from(triggerModel) - .where(eq(triggerModel.workspaceId, workspaceId)) - } - /** * SQL-paginated triggers with their real `conditions` joined in — for the * public API's `GET /v1/triggers`, which previously loaded every trigger diff --git a/packages/database/src/repositories/trigger/repository.ts b/packages/database/src/repositories/trigger/repository.ts index dd1fb839d7..deae9580ec 100644 --- a/packages/database/src/repositories/trigger/repository.ts +++ b/packages/database/src/repositories/trigger/repository.ts @@ -75,6 +75,7 @@ export const triggerRepository = { tx.query.triggerModel.findMany({ where: { workspaceId: input.workspaceId }, with: { conditions: true }, + orderBy: { createdAt: "desc", id: "desc" }, limit: input.limit, offset: input.offset, }), From 96470874f5a8016ed26fff62d7632386622471df Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 08:01:24 +0700 Subject: [PATCH 6/8] fix(automation): unify trigger/webhook list pagination and drop redundant broadcast query Closes the remaining data-access gaps from the flows/triggers/sequences/ broadcasts/webhooks refactor: the public API and builder pages for triggers and webhooks each maintained a separate, diverging list implementation, and the webhook public API loaded every row in the workspace before paginating in memory. - webhookService.list: SQL-paginated, conditions joined, shared by GET /v1/webhooks and the builder's webhooks page (replaces the unbounded listByWorkspaceId + paginateInMemory path) - triggerService.list: extended with folderId/name filters so the builder's triggers page can share it with GET /v1/triggers instead of hand-rolling a second, unordered pagination; added triggerService.findWithConditions to remove three direct repository reads from the public API - resend-broadcast.action: read contactFilter off assertResendable's already-fetched row instead of issuing a second query for it - create-broadcast.action: use isValidationException instead of a duck-typed error check, matching every sibling action - flow detail pages: call flowService.findById instead of flowRepository directly --- .../__tests__/create-broadcast.action.test.ts | 3 +- .../__tests__/resend-broadcast.action.test.ts | 34 +++---- .../__tests__/triggers-public-api.test.ts | 21 ++-- .../__tests__/webhooks-public-api.test.ts | 17 ++-- .../flows/[id]/analytics/page.tsx | 14 +-- .../space/[workspaceId]/flows/[id]/page.tsx | 14 +-- .../actions/create-broadcast.action.ts | 11 +-- .../actions/resend-broadcast.action.ts | 21 ++-- .../src/features/triggers/api/public.ts | 9 +- .../src/features/triggers/queries/index.ts | 27 +----- .../src/features/webhooks/api/public.ts | 18 ++-- .../src/features/webhooks/queries/index.ts | 27 +----- .../webhook-service-builder-methods.test.ts | 5 + ...ook-service-update-with-conditions.test.ts | 5 + .../__tests__/webhook.service.test.ts | 60 ++++++++++++ packages/business/src/trigger/service.ts | 24 ++++- packages/business/src/webhook/service.ts | 49 +++++++++- .../__tests__/trigger-repository.test.ts | 97 ++++++++++++------- .../src/repositories/flow/repository.ts | 2 +- .../src/repositories/trigger/repository.ts | 50 ++++------ 20 files changed, 293 insertions(+), 215 deletions(-) diff --git a/apps/builder/__tests__/create-broadcast.action.test.ts b/apps/builder/__tests__/create-broadcast.action.test.ts index 030785c5be..ff082ef7cc 100644 --- a/apps/builder/__tests__/create-broadcast.action.test.ts +++ b/apps/builder/__tests__/create-broadcast.action.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node +import { validationException } from "@chatbotx.io/business/errors" import { beforeEach, describe, expect, test, vi } from "vitest" const { @@ -66,7 +67,7 @@ const baseInput = { } const validationError = (field: string, message: string) => - Object.assign(new Error(message), { code: "validation", field }) + validationException(field, message) beforeEach(() => { vi.clearAllMocks() diff --git a/apps/builder/__tests__/resend-broadcast.action.test.ts b/apps/builder/__tests__/resend-broadcast.action.test.ts index 022581b386..ac8f003367 100644 --- a/apps/builder/__tests__/resend-broadcast.action.test.ts +++ b/apps/builder/__tests__/resend-broadcast.action.test.ts @@ -5,12 +5,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { mockResend, mockAssertResendable, - mockFindContactFilter, mockGetCurrentUserAndTargetWorkspace, } = vi.hoisted(() => ({ mockResend: vi.fn(), mockAssertResendable: vi.fn().mockResolvedValue({ id: "bc-1" }), - mockFindContactFilter: vi.fn(), mockGetCurrentUserAndTargetWorkspace: vi.fn().mockResolvedValue({ targetWorkspaceMember: { permissions: ["emailAndPhone"] }, }), @@ -31,10 +29,6 @@ vi.mock("@chatbotx.io/business", () => ({ }, })) -vi.mock("@chatbotx.io/database/repositories", () => ({ - broadcastRepository: { findContactFilter: mockFindContactFilter }, -})) - vi.mock("@chatbotx.io/database/queries/contact-filter/permission", () => ({ pruneEmailPhoneFilterConditions: (contactFilter: unknown) => contactFilter ?? undefined, @@ -64,16 +58,19 @@ const BROADCAST_ID = "bc-1" describe("resendBroadcast", () => { beforeEach(() => { vi.clearAllMocks() - mockAssertResendable.mockResolvedValue({ id: BROADCAST_ID }) + mockAssertResendable.mockResolvedValue({ + id: BROADCAST_ID, + contactFilter: null, + }) mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({ targetWorkspaceMember: { permissions: ["emailAndPhone"] }, }) - mockFindContactFilter.mockResolvedValue({ contactFilter: null }) }) - test("reads the source broadcast's contact filter and delegates to broadcastService.resend", async () => { + test("reads the source broadcast's contact filter from assertResendable and delegates to broadcastService.resend", async () => { mockResend.mockResolvedValue({ id: "new-bc-id" }) - mockFindContactFilter.mockResolvedValue({ + mockAssertResendable.mockResolvedValue({ + id: BROADCAST_ID, contactFilter: { operator: "and", conditions: [] }, }) @@ -82,9 +79,9 @@ describe("resendBroadcast", () => { id: BROADCAST_ID, }) - expect(mockFindContactFilter).toHaveBeenCalledWith({ - id: BROADCAST_ID, + expect(mockAssertResendable).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, + id: BROADCAST_ID, }) expect(mockResend).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, @@ -94,33 +91,32 @@ describe("resendBroadcast", () => { expect(result).toEqual({ id: "new-bc-id" }) }) - test("propagates a 'Broadcast is not sent' error from assertResendable, before reading the contact filter", async () => { + test("propagates a 'Broadcast is not sent' error from assertResendable", async () => { mockAssertResendable.mockRejectedValue(new Error("Broadcast is not sent")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), ).rejects.toThrow("Broadcast is not sent") - // The guard runs before the contact-filter read — a not-resendable - // broadcast's filter is never touched. - expect(mockFindContactFilter).not.toHaveBeenCalled() expect(mockResend).not.toHaveBeenCalled() }) - test("propagates a not-found error when the source broadcast is missing, before reading the contact filter", async () => { + test("propagates a not-found error when the source broadcast is missing", async () => { mockAssertResendable.mockRejectedValue(new Error("Record not found")) await expect( resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }), ).rejects.toThrow("Record not found") - expect(mockFindContactFilter).not.toHaveBeenCalled() expect(mockResend).not.toHaveBeenCalled() }) test("passes undefined contactFilter when the source has none stored", async () => { mockResend.mockResolvedValue({ id: "new-bc-id" }) - mockFindContactFilter.mockResolvedValue(undefined) + mockAssertResendable.mockResolvedValue({ + id: BROADCAST_ID, + contactFilter: undefined, + }) await resendBroadcast({ workspaceId: WORKSPACE_ID, id: BROADCAST_ID }) diff --git a/apps/builder/__tests__/triggers-public-api.test.ts b/apps/builder/__tests__/triggers-public-api.test.ts index 99b17750c6..99d50d5ea8 100644 --- a/apps/builder/__tests__/triggers-public-api.test.ts +++ b/apps/builder/__tests__/triggers-public-api.test.ts @@ -48,6 +48,7 @@ vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) const triggerService = { list: vi.fn(), + findWithConditions: vi.fn(), create: vi.fn(), updateWithConditions: vi.fn(), updateSettings: vi.fn(), @@ -59,14 +60,10 @@ vi.mock("@chatbotx.io/business/errors", () => ({ notFoundException: (message: string) => new Error(message), })) -const triggerRepository = { - findWithConditions: vi.fn(), -} const conditionRepository = { listByTriggerIds: vi.fn(), } vi.mock("@chatbotx.io/database/repositories", () => ({ - triggerRepository, conditionRepository, })) @@ -141,8 +138,8 @@ describe("GET /v1/triggers", () => { describe("GET /v1/triggers/{id}", () => { const procedure = findProcedure("GET", "/v1/triggers/{id}") - test("delegates to triggerRepository.findWithConditions", async () => { - triggerRepository.findWithConditions.mockResolvedValueOnce({ + test("delegates to triggerService.findWithConditions", async () => { + triggerService.findWithConditions.mockResolvedValueOnce({ id: "trigger-1", conditions: [], actions: [], @@ -153,14 +150,14 @@ describe("GET /v1/triggers/{id}", () => { input: { id: "trigger-1" }, }) - expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ + expect(triggerService.findWithConditions).toHaveBeenCalledWith({ id: "trigger-1", workspaceId: "workspace-1", }) }) test("throws not found when the trigger does not exist", async () => { - triggerRepository.findWithConditions.mockResolvedValueOnce(null) + triggerService.findWithConditions.mockResolvedValueOnce(null) await expect( procedure.handler?.({ @@ -219,7 +216,7 @@ describe("PUT /v1/triggers/{id}", () => { conditions: [{ type: "newContact" }], }) // No redundant re-read of the trigger row itself — only conditions. - expect(triggerRepository.findWithConditions).not.toHaveBeenCalled() + expect(triggerService.findWithConditions).not.toHaveBeenCalled() expect(conditionRepository.listByTriggerIds).toHaveBeenCalledWith([ "trigger-1", ]) @@ -243,7 +240,7 @@ describe("PATCH /v1/triggers/{id}/settings", () => { test("delegates to triggerService.updateSettings and returns the updated resource", async () => { triggerService.updateSettings.mockResolvedValueOnce(undefined) - triggerRepository.findWithConditions.mockResolvedValueOnce({ + triggerService.findWithConditions.mockResolvedValueOnce({ id: "trigger-1", active: false, conditions: [], @@ -260,7 +257,7 @@ describe("PATCH /v1/triggers/{id}/settings", () => { id: "trigger-1", active: false, }) - expect(triggerRepository.findWithConditions).toHaveBeenCalledWith({ + expect(triggerService.findWithConditions).toHaveBeenCalledWith({ id: "trigger-1", workspaceId: "workspace-1", }) @@ -269,7 +266,7 @@ describe("PATCH /v1/triggers/{id}/settings", () => { test("throws not found when the trigger no longer exists after updateSettings", async () => { triggerService.updateSettings.mockResolvedValueOnce(undefined) - triggerRepository.findWithConditions.mockResolvedValueOnce(null) + triggerService.findWithConditions.mockResolvedValueOnce(null) await expect( procedure.handler?.({ diff --git a/apps/builder/__tests__/webhooks-public-api.test.ts b/apps/builder/__tests__/webhooks-public-api.test.ts index 4eef6429d5..f26c8f5e39 100644 --- a/apps/builder/__tests__/webhooks-public-api.test.ts +++ b/apps/builder/__tests__/webhooks-public-api.test.ts @@ -47,7 +47,7 @@ const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) const webhookService = { - listByWorkspaceId: vi.fn(), + list: vi.fn(), register: vi.fn(), unregister: vi.fn(), } @@ -100,10 +100,11 @@ describe("GET /v1/webhooks", () => { ) }) - test("delegates to webhookService.listByWorkspaceId", async () => { - webhookService.listByWorkspaceId.mockResolvedValueOnce([ - { id: "webhook-1" }, - ]) + test("delegates to webhookService.list", async () => { + webhookService.list.mockResolvedValueOnce({ + data: [{ id: "webhook-1" }], + pageCount: 1, + }) await expect( procedure.handler?.({ @@ -112,7 +113,11 @@ describe("GET /v1/webhooks", () => { }), ).resolves.toEqual({ data: [{ id: "webhook-1" }], pageCount: 1 }) - expect(webhookService.listByWorkspaceId).toHaveBeenCalledWith("workspace-1") + expect(webhookService.list).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + page: 1, + perPage: 50, + }) }) }) diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx index a970b95584..e544aef9b0 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx @@ -1,6 +1,6 @@ import { flowAnalyticsService } from "@chatbotx.io/analytics" +import { flowService } from "@chatbotx.io/business" import { smartDelayService } from "@chatbotx.io/business/smart-delay" -import { flowRepository } from "@chatbotx.io/database/repositories" import type { FlowNode } from "@chatbotx.io/flow-config" import { notFound } from "next/navigation" import type { FlowVersionResource } from "@/features/flow-versions/schema/resource" @@ -23,11 +23,13 @@ export default async function FlowAnalyticsPage({ await requireWorkspacePermission(data.workspaceId, "flows") - const flow = await flowRepository.findWithVersions({ - id: data.id, - workspaceId: data.workspaceId, - }) - if (!flow) { + let flow: Awaited> + try { + flow = await flowService.findById({ + id: data.id, + workspaceId: data.workspaceId, + }) + } catch { return notFound() } diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx index ea0c102521..25392c733b 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx @@ -1,4 +1,4 @@ -import { flowRepository } from "@chatbotx.io/database/repositories" +import { flowService } from "@chatbotx.io/business" import { notFound } from "next/navigation" import { FlowDetail } from "@/features/flows/flow-detail" import { isSameContent } from "@/features/flows/flow-version-content" @@ -18,11 +18,13 @@ export default async function FlowPage({ params }: FlowPageProps) { await requireWorkspacePermission(data.workspaceId, "flows") - const flow = await flowRepository.findWithVersions({ - id: data.id, - workspaceId: data.workspaceId, - }) - if (!flow) { + let flow: Awaited> + try { + flow = await flowService.findById({ + id: data.id, + workspaceId: data.workspaceId, + }) + } catch { return notFound() } diff --git a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts index f750026fb9..de7181cdde 100644 --- a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts @@ -5,6 +5,7 @@ import { returnValidationErrors } from "next-safe-action" import { workspaceIdrequestParams } from "@/features/common/schema" import { canViewContactEmailAndPhone } from "@/features/contacts/permissions" import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils" +import { isValidationException } from "@/lib/errors/validation-exception" import { workspaceActionClient } from "@/lib/safe-action" import { createBroadcastRequest } from "../schema/action" @@ -31,16 +32,10 @@ export const createBroadcastAction = workspaceActionClient canViewEmailAndPhone, }) } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "validation" && - "field" in error - ) { - const field = error.field as string + if (isValidationException(error) && error.field) { return returnValidationErrors(createBroadcastRequest, { _errors: ["Validation Exception"], - [field]: { + [error.field]: { _errors: [error.message], }, }) diff --git a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts index 382df8bcc8..babcc1f76f 100644 --- a/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts +++ b/apps/builder/src/features/broadcasts/actions/resend-broadcast.action.ts @@ -2,7 +2,6 @@ import { broadcastService } from "@chatbotx.io/business" import { pruneEmailPhoneFilterConditions } from "@chatbotx.io/database/queries/contact-filter/permission" -import { broadcastRepository } from "@chatbotx.io/database/repositories" import { zodBigintAsString } from "@chatbotx.io/utils" import { contactFilterCriteriaSchema } from "@/features/contact-filter/schema" import { canViewContactEmailAndPhone } from "@/features/contacts/permissions" @@ -24,13 +23,12 @@ export const resendBroadcast = async (ctx: { id: string }) => { // Verify the broadcast exists (not soft-deleted, in-workspace) and is in - // a resendable status before reading its `contactFilter` — main checked - // existence first; reading before the guard would let a foreign or - // deleted id be processed. `broadcastService.resend` re-asserts this - // itself before its own insert, so the check is intentionally duplicated - // rather than redundant: this pre-check exists to guard the - // `contactFilter` read below, not the resend itself. - await broadcastService.assertResendable({ + // a resendable status, and read its persisted `contactFilter` in the same + // call — `broadcastService.resend` re-asserts existence itself before its + // own insert, so this pre-check is intentionally duplicated rather than + // redundant, but it now also supplies the row so no second query is made + // for `contactFilter`. + const broadcast = await broadcastService.assertResendable({ workspaceId: ctx.workspaceId, id: ctx.id, }) @@ -39,13 +37,8 @@ export const resendBroadcast = async (ctx: { ctx.workspaceId, ) - const broadcast = await broadcastRepository.findContactFilter({ - id: ctx.id, - workspaceId: ctx.workspaceId, - }) - const persistedContactFilter = contactFilterCriteriaSchema.safeParse( - broadcast?.contactFilter, + broadcast.contactFilter, ) const contactFilter = pruneEmailPhoneFilterConditions( persistedContactFilter.success ? persistedContactFilter.data : undefined, diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 50263469a4..29745b61c6 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -1,10 +1,7 @@ import { triggerService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { folderTypes } from "@chatbotx.io/database/partials" -import { - conditionRepository, - triggerRepository, -} from "@chatbotx.io/database/repositories" +import { conditionRepository } from "@chatbotx.io/database/repositories" import type { TriggerModel } from "@chatbotx.io/database/types" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -70,7 +67,7 @@ export const triggersPublicRouter = { .output(triggerResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { - const trigger = await triggerRepository.findWithConditions({ + const trigger = await triggerService.findWithConditions({ id: input.id, workspaceId: context.workspace.id, }) @@ -150,7 +147,7 @@ export const triggersPublicRouter = { id, ...patch, }) - const updated = await triggerRepository.findWithConditions({ + const updated = await triggerService.findWithConditions({ id, workspaceId: context.workspace.id, }) diff --git a/apps/builder/src/features/triggers/queries/index.ts b/apps/builder/src/features/triggers/queries/index.ts index a8eff867b9..296ae6fbc9 100644 --- a/apps/builder/src/features/triggers/queries/index.ts +++ b/apps/builder/src/features/triggers/queries/index.ts @@ -1,7 +1,4 @@ -import { - conditionRepository, - triggerRepository, -} from "@chatbotx.io/database/repositories" +import { triggerService } from "@chatbotx.io/business" import type { TriggerModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { GetTriggersSchema, ListTriggersResponse } from "../schema/query" @@ -11,25 +8,7 @@ export async function getTriggers( ): Promise { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const { rows: triggers, total } = await triggerRepository.listPaginated({ - workspaceId: input.workspaceId, - folderId: input.folderId, - name: input.name, - limit: input.perPage, - offset: (input.page - 1) * input.perPage, - }) - - const triggerIds = triggers.map((t) => t.id) - const conditionsData = await conditionRepository.listByTriggerIds(triggerIds) - - const data = triggers.map((trigger) => ({ - ...trigger, - conditions: conditionsData.filter((c) => c.triggerId === trigger.id), - })) - - const pageCount = Math.ceil(total / input.perPage) - - return { data, pageCount } + return await triggerService.list(input) } export async function findTrigger(params: { @@ -40,5 +19,5 @@ export async function findTrigger(params: { return null } - return await triggerRepository.findWithConditions(params) + return await triggerService.findWithConditions(params) } diff --git a/apps/builder/src/features/webhooks/api/public.ts b/apps/builder/src/features/webhooks/api/public.ts index c50ff2e04a..ed23905514 100644 --- a/apps/builder/src/features/webhooks/api/public.ts +++ b/apps/builder/src/features/webhooks/api/public.ts @@ -7,11 +7,7 @@ import { possibleErrorsOnDeletingResource, possibleErrorsOnListingResource, } from "@/lib/orpc/orpc-error-helper" -import { - paginateInMemory, - publicListRequest, - publicListResponse, -} from "@/lib/public-api/list" +import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { conditionSchema } from "../../conditions/schema" @@ -36,10 +32,14 @@ export const webhooksPublicRouter = { .input(publicListRequest) .output(publicListResponse(publicWebhookResource)) .errors(possibleErrorsOnListingResource) - .handler(async ({ context, input }) => { - const data = await webhookService.listByWorkspaceId(context.workspace.id) - return paginateInMemory(data, input) - }), + .handler( + async ({ context, input }) => + await webhookService.list({ + workspaceId: context.workspace.id, + page: input.page, + perPage: input.perPage, + }), + ), create: workspaceTokenAuthAPI .route({ diff --git a/apps/builder/src/features/webhooks/queries/index.ts b/apps/builder/src/features/webhooks/queries/index.ts index aa27ea52ae..bcefeef54e 100644 --- a/apps/builder/src/features/webhooks/queries/index.ts +++ b/apps/builder/src/features/webhooks/queries/index.ts @@ -1,8 +1,5 @@ -import { - conditionRepository, - findWebhookWithConditions, - listWebhooksPaginated, -} from "@chatbotx.io/database/repositories" +import { webhookService } from "@chatbotx.io/business" +import { findWebhookWithConditions } from "@chatbotx.io/database/repositories" import type { WebhookModel } from "@chatbotx.io/database/types" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { WebhookCollection } from "../schema" @@ -13,25 +10,7 @@ export async function getWebhooks( ): Promise { await assertCurrentUserCanAccessChatbot(input.workspaceId) - const { rows: webhooks, total } = await listWebhooksPaginated({ - workspaceId: input.workspaceId, - folderId: input.folderId, - name: input.name, - limit: input.perPage, - offset: (input.page - 1) * input.perPage, - }) - - const webhookIds = webhooks.map((w) => w.id) - const conditionsData = await conditionRepository.listByWebhookIds(webhookIds) - - const data = webhooks.map((webhook) => ({ - ...webhook, - conditions: conditionsData.filter((c) => c.webhookId === webhook.id), - })) - - const pageCount = Math.ceil(total / input.perPage) - - return { data, pageCount } + return await webhookService.list(input) } export async function findWebhook(params: { diff --git a/packages/business/__tests__/webhook-service-builder-methods.test.ts b/packages/business/__tests__/webhook-service-builder-methods.test.ts index 3cbd6acc8f..1d8e9d4ff2 100644 --- a/packages/business/__tests__/webhook-service-builder-methods.test.ts +++ b/packages/business/__tests__/webhook-service-builder-methods.test.ts @@ -70,6 +70,11 @@ vi.mock("@chatbotx.io/database/schema", () => ({ }, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + listWebhooksPaginated: vi.fn(), + conditionRepository: { listByWebhookIds: vi.fn() }, +})) + vi.mock("@chatbotx.io/events", () => ({ updateWebhookCache: mockUpdateWebhookCache, removeWebhookCache: mockRemoveWebhookCache, diff --git a/packages/business/__tests__/webhook-service-update-with-conditions.test.ts b/packages/business/__tests__/webhook-service-update-with-conditions.test.ts index 8f41092678..9beef62f20 100644 --- a/packages/business/__tests__/webhook-service-update-with-conditions.test.ts +++ b/packages/business/__tests__/webhook-service-update-with-conditions.test.ts @@ -65,6 +65,11 @@ vi.mock("@chatbotx.io/database/schema", () => ({ }, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + listWebhooksPaginated: vi.fn(), + conditionRepository: { listByWebhookIds: vi.fn() }, +})) + vi.mock("@chatbotx.io/events", () => ({ removeWebhookCache: vi.fn(), updateWebhookCache: mockUpdateWebhookCache, diff --git a/packages/business/__tests__/webhook.service.test.ts b/packages/business/__tests__/webhook.service.test.ts index c3a8bbacb7..5ad5fc728a 100644 --- a/packages/business/__tests__/webhook.service.test.ts +++ b/packages/business/__tests__/webhook.service.test.ts @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => { async (fn: (tx: typeof tx) => Promise) => await fn(tx), ), deleteFn: vi.fn(() => deleteBuilder), + listWebhooksPaginated: vi.fn(), + listByWebhookIds: vi.fn(async () => []), } }) @@ -49,6 +51,11 @@ vi.mock("@chatbotx.io/database/schema", () => ({ conditionModel: mocks.conditionModel, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + listWebhooksPaginated: mocks.listWebhooksPaginated, + conditionRepository: { listByWebhookIds: mocks.listByWebhookIds }, +})) + vi.mock("@chatbotx.io/events", () => ({ updateWebhookCache: vi.fn(async () => undefined), removeWebhookCache: vi.fn(async () => undefined), @@ -274,3 +281,56 @@ describe("webhookService.create", () => { expect(result).toEqual({ id: "webhook-1" }) }) }) + +describe("webhookService.list", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("paginates via listWebhooksPaginated and joins conditions per row", async () => { + mocks.listWebhooksPaginated.mockResolvedValue({ + rows: [{ id: "webhook-1" }, { id: "webhook-2" }], + total: 21, + }) + mocks.listByWebhookIds.mockResolvedValue([ + { id: "c1", webhookId: "webhook-1" }, + { id: "c2", webhookId: "webhook-2" }, + ]) + + const result = await webhookService.list({ + workspaceId: "workspace-1", + page: 1, + perPage: 10, + }) + + expect(mocks.listWebhooksPaginated).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + folderId: undefined, + name: undefined, + limit: 10, + offset: 0, + }) + expect(mocks.listByWebhookIds).toHaveBeenCalledWith([ + "webhook-1", + "webhook-2", + ]) + expect(result.data).toEqual([ + { id: "webhook-1", conditions: [{ id: "c1", webhookId: "webhook-1" }] }, + { id: "webhook-2", conditions: [{ id: "c2", webhookId: "webhook-2" }] }, + ]) + expect(result.pageCount).toBe(3) + }) + + test("returns pageCount 0 for an empty workspace", async () => { + mocks.listWebhooksPaginated.mockResolvedValue({ rows: [], total: 0 }) + mocks.listByWebhookIds.mockResolvedValue([]) + + const result = await webhookService.list({ + workspaceId: "workspace-1", + page: 1, + perPage: 10, + }) + + expect(result).toEqual({ data: [], pageCount: 0 }) + }) +}) diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index 36ec27556a..7c8abb2d99 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -66,12 +66,15 @@ class TriggerService extends BaseService { } /** - * SQL-paginated triggers with their real `conditions` joined in — for the - * public API's `GET /v1/triggers`, which previously loaded every trigger - * in the workspace and re-queried each one individually. + * SQL-paginated triggers with their real `conditions` joined in — shared + * by the public API's `GET /v1/triggers` and the builder's triggers page, + * so both paginate identically instead of the builder hand-rolling a + * second implementation. */ async list(input: { workspaceId: string + folderId?: string | null + name?: string page: number perPage: number }): Promise<{ @@ -83,6 +86,8 @@ class TriggerService extends BaseService { const { rows, total } = await triggerRepository.listPaginatedWithConditions( { workspaceId: input.workspaceId, + folderId: input.folderId, + name: input.name, limit: input.perPage, offset: (input.page - 1) * input.perPage, }, @@ -90,10 +95,21 @@ class TriggerService extends BaseService { return { data: rows, - pageCount: Math.max(1, Math.ceil(total / input.perPage)), + pageCount: Math.ceil(total / input.perPage), } } + /** A single trigger with its real `conditions` joined in. */ + async findWithConditions(params: { + id?: string + workspaceId?: string + }): Promise< + | (TriggerModel & { conditions: (typeof conditionModel.$inferSelect)[] }) + | null + > { + return await triggerRepository.findWithConditions(params) + } + async deleteMany(input: { workspaceId: string ids: string[] diff --git a/packages/business/src/webhook/service.ts b/packages/business/src/webhook/service.ts index b708c5bed6..7c2ebd1c6f 100644 --- a/packages/business/src/webhook/service.ts +++ b/packages/business/src/webhook/service.ts @@ -1,5 +1,9 @@ import { and, db, eq, inArray } from "@chatbotx.io/database/client" import type { FolderType } from "@chatbotx.io/database/partials" +import { + conditionRepository, + listWebhooksPaginated, +} from "@chatbotx.io/database/repositories" import { conditionModel, webhookModel } from "@chatbotx.io/database/schema" import type { WebhookModel } from "@chatbotx.io/database/types" import { removeWebhookCache, updateWebhookCache } from "@chatbotx.io/events" @@ -29,11 +33,46 @@ export type WebhookConditionInput = { } class WebhookService extends BaseService { - async listByWorkspaceId(workspaceId: string): Promise { - return await db - .select() - .from(webhookModel) - .where(eq(webhookModel.workspaceId, workspaceId)) + /** + * SQL-paginated webhook list with conditions joined in — shared by the + * public API (`GET /v1/webhooks`) and the builder's webhooks page, so both + * paginate identically instead of one loading the whole workspace. + */ + async list(input: { + workspaceId: string + folderId?: string | null + name?: string + page: number + perPage: number + }): Promise<{ + data: (WebhookModel & { + conditions: (typeof conditionModel.$inferSelect)[] + })[] + pageCount: number + }> { + const { rows, total } = await listWebhooksPaginated({ + workspaceId: input.workspaceId, + folderId: input.folderId, + name: input.name, + limit: input.perPage, + offset: (input.page - 1) * input.perPage, + }) + + const webhookIds = rows.map((webhook) => webhook.id) + const conditionsData = + await conditionRepository.listByWebhookIds(webhookIds) + + const data = rows.map((webhook) => ({ + ...webhook, + conditions: conditionsData.filter( + (condition) => condition.webhookId === webhook.id, + ), + })) + + return { + data, + pageCount: Math.ceil(total / input.perPage), + } } /** diff --git a/packages/database/__tests__/trigger-repository.test.ts b/packages/database/__tests__/trigger-repository.test.ts index 05fd5cf9c6..7ff58eba9a 100644 --- a/packages/database/__tests__/trigger-repository.test.ts +++ b/packages/database/__tests__/trigger-repository.test.ts @@ -2,35 +2,19 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const mocks = vi.hoisted(() => { - const selectWhereLimitOffset = { - limit: vi.fn(), - } - const selectWhere = { - limit: vi.fn(() => selectWhereLimitOffset), - where: vi.fn(), - } - const selectFrom = { - where: vi.fn(() => selectWhere), - } - const select = vi.fn(() => ({ from: vi.fn(() => selectFrom) })) - return { - select, - selectFrom, - selectWhere, - selectWhereLimitOffset, - findFirst: vi.fn(), - } -}) +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + findMany: vi.fn(), + $count: vi.fn(), +})) vi.mock("@chatbotx.io/database/client", () => ({ and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), - count: vi.fn(() => "count-expr"), db: { - select: mocks.select, query: { - triggerModel: { findFirst: mocks.findFirst }, + triggerModel: { findFirst: mocks.findFirst, findMany: mocks.findMany }, }, + $count: mocks.$count, }, eq: vi.fn((field: unknown, value: unknown) => ({ eq: [field, value] })), isNull: vi.fn((field: unknown) => ({ isNull: field })), @@ -44,25 +28,17 @@ const { triggerRepository } = await import( "../src/repositories/trigger/repository" ) -describe("triggerRepository.listPaginated", () => { +describe("triggerRepository.listPaginatedWithConditions", () => { beforeEach(() => { vi.clearAllMocks() }) test("resolves an empty-string folderId to isNull (trigger sentinel, not rootFolderId)", async () => { - const rows = [{ id: "trigger-1" }] - const offsetFn = vi.fn().mockResolvedValue(rows) - mocks.selectWhere.limit.mockReturnValue({ offset: offsetFn }) - const countBuilder = { where: vi.fn().mockResolvedValue([{ count: 1 }]) } - mocks.select - .mockReturnValueOnce({ - from: vi.fn(() => ({ where: vi.fn(() => mocks.selectWhere) })), - }) - .mockReturnValueOnce({ - from: vi.fn(() => countBuilder), - }) - - const result = await triggerRepository.listPaginated({ + const rows = [{ id: "trigger-1", conditions: [] }] + mocks.findMany.mockResolvedValue(rows) + mocks.$count.mockResolvedValue(1) + + const result = await triggerRepository.listPaginatedWithConditions({ workspaceId: "ws-1", folderId: "", limit: 10, @@ -71,6 +47,53 @@ describe("triggerRepository.listPaginated", () => { expect(result.rows).toEqual(rows) expect(result.total).toBe(1) + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "ws-1", + folderId: { isNull: true }, + }), + with: { conditions: true }, + }), + ) + }) + + test("filters by folderId and name when provided", async () => { + mocks.findMany.mockResolvedValue([]) + mocks.$count.mockResolvedValue(0) + + await triggerRepository.listPaginatedWithConditions({ + workspaceId: "ws-1", + folderId: "folder-1", + name: "Welcome", + limit: 10, + offset: 0, + }) + + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "ws-1", + folderId: "folder-1", + name: "Welcome", + }), + }), + ) + }) + + test("omits folderId/name filters entirely when not provided", async () => { + mocks.findMany.mockResolvedValue([]) + mocks.$count.mockResolvedValue(0) + + await triggerRepository.listPaginatedWithConditions({ + workspaceId: "ws-1", + limit: 10, + offset: 0, + }) + + const call = mocks.findMany.mock.calls[0]?.[0] + expect(call.where).not.toHaveProperty("folderId") + expect(call.where).not.toHaveProperty("name") }) }) diff --git a/packages/database/src/repositories/flow/repository.ts b/packages/database/src/repositories/flow/repository.ts index 95d4c31795..9393f73e03 100644 --- a/packages/database/src/repositories/flow/repository.ts +++ b/packages/database/src/repositories/flow/repository.ts @@ -59,7 +59,7 @@ export const flowRepository = { return await tx.$count(flowModel, relationsFilterToSQL(flowModel, where)) }, - /** Flow detail with all versions — shared by both flow detail pages. */ + /** Flow detail with all versions — used by flowService.findById. */ async findWithVersions( input: { workspaceId: string; id: string }, tx: DatabaseClient = db, diff --git a/packages/database/src/repositories/trigger/repository.ts b/packages/database/src/repositories/trigger/repository.ts index deae9580ec..87f3ad0b08 100644 --- a/packages/database/src/repositories/trigger/repository.ts +++ b/packages/database/src/repositories/trigger/repository.ts @@ -1,4 +1,4 @@ -import { and, count, type DatabaseClient, db, eq, isNull } from "../../client" +import { and, type DatabaseClient, db, eq, isNull } from "../../client" import { triggerModel } from "../../schema" const buildWhere = (input: { @@ -27,11 +27,12 @@ const buildWhere = (input: { export const triggerRepository = { /** - * Paginated trigger rows, SQL-builder style — preserves the exact - * `folderId === null || ""` → `isNull` semantics that triggers use (unlike - * webhooks, which use `rootFolderId`). Do not unify the two. + * Paginated trigger rows with their real `conditions` joined in, SQL-level + * — shared by the public API's `GET /v1/triggers` and the builder's + * triggers page, so both paginate identically instead of the builder + * hand-rolling a second implementation. */ - async listPaginated( + async listPaginatedWithConditions( input: { workspaceId: string folderId?: string | null @@ -43,37 +44,20 @@ export const triggerRepository = { ) { const whereClause = buildWhere(input) - const [rows, countResult] = await Promise.all([ - tx - .select() - .from(triggerModel) - .where(whereClause) - .limit(input.limit) - .offset(input.offset), - tx.select({ count: count() }).from(triggerModel).where(whereClause), - ]) - - return { rows, total: countResult[0]?.count ?? 0 } - }, - - /** - * Paginated trigger rows with their real `conditions` joined in, SQL-level - * — for the public API's `GET /v1/triggers`, which needs the same shape - * as `findWithConditions` but for a page of rows instead of one. - */ - async listPaginatedWithConditions( - input: { - workspaceId: string - limit: number - offset: number - }, - tx: DatabaseClient = db, - ) { - const whereClause = eq(triggerModel.workspaceId, input.workspaceId) + const relationalFolderId = + input.folderId === null || input.folderId === "" + ? { isNull: true as const } + : input.folderId const [rows, total] = await Promise.all([ tx.query.triggerModel.findMany({ - where: { workspaceId: input.workspaceId }, + where: { + workspaceId: input.workspaceId, + ...(input.folderId === undefined + ? {} + : { folderId: relationalFolderId }), + ...(input.name ? { name: input.name } : {}), + }, with: { conditions: true }, orderBy: { createdAt: "desc", id: "desc" }, limit: input.limit, From d480edf2a02998eac440da3ebfd9818c0c31d4b0 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 13:08:00 +0700 Subject: [PATCH 7/8] fix(automation): move audience/list reads and step scheduling into business services Closes remaining direct-repository access from the builder app layer for broadcasts (list, audience, findByIdOrName), sequences (list, findWithSteps), and moves sequence-step contact-schedule recalculation into packages/business/src/sequence, alongside triggers/ai-agents write handlers now returning their created/updated model instead of a redundant follow-up findBy. Automated-response reads/writes are scoped by type end-to-end (inbound vs outbound) to prevent cross-type leaks. --- .../__tests__/ai-agents-public-api.test.ts | 27 +- .../automated-response-type-scope.test.ts | 116 +++++++++ .../__tests__/broadcasts-public-scope.test.ts | 2 +- .../__tests__/create-sequence.action.test.ts | 2 +- .../delete-sequence-step.action.test.ts | 27 +- .../__tests__/delete-sequence.action.test.ts | 2 +- .../__tests__/keywords-public-api.test.ts | 93 ++++++- .../__tests__/list-broadcast-audience.test.ts | 104 -------- .../public-list-queries-no-session.test.ts | 47 ++++ .../__tests__/triggers-public-api.test.ts | 37 +-- .../upsert-sequence-step.action.test.ts | 243 ++++-------------- .../flows/[id]/analytics/page.tsx | 18 +- .../automated-responses/[id]/edit/page.tsx | 7 +- .../[id]/edit/page.tsx | 7 +- .../src/features/ai-agents/api/public.ts | 23 +- .../delete-automated-response-action.ts | 11 +- .../enable-automated-response-action.ts | 17 +- .../update-automated-response-action.ts | 19 +- .../features/automated-response/api/public.ts | 41 +-- ...tomated-response-table-toolbar-actions.tsx | 8 +- .../automated-response-table.tsx | 13 +- .../delete-automated-response-dialog.tsx | 5 +- .../edit-automated-response-form.tsx | 11 +- .../automated-response/schema/query.ts | 6 +- .../src/features/broadcasts/api/public.ts | 33 ++- .../src/features/broadcasts/queries/index.ts | 75 +----- .../flows/actions/publish-flow-action.ts | 6 +- .../actions/create-sequence.action.ts | 10 +- .../actions/delete-sequence-step.action.ts | 4 +- .../actions/delete-sequence.action.ts | 2 +- .../actions/update-sequence.action.ts | 2 +- .../actions/upsert-sequence-step.action.ts | 62 +---- .../src/features/sequences/api/public.ts | 8 +- .../src/features/sequences/queries/index.ts | 28 +- .../src/features/triggers/api/public.ts | 16 +- .../__tests__/ai-agent.service.test.ts | 3 +- .../automated-response.service.test.ts | 131 ++++++++-- .../broadcast-service-audience.test.ts | 86 +++++++ .../broadcast-service-create.test.ts | 12 + .../broadcast-service-resend.test.ts | 12 + .../broadcast-service-transitions.test.ts | 12 + .../broadcast-service-update.test.ts | 12 + .../__tests__/sequence-service.test.ts | 99 ++++++- .../trigger-service-update-settings.test.ts | 19 +- ...ger-service-update-with-conditions.test.ts | 2 +- packages/business/package.json | 2 + packages/business/src/ai-agent/service.ts | 56 +++- .../src/automated-response/service.ts | 22 +- packages/business/src/broadcast/service.ts | 92 ++++++- packages/business/src/index.ts | 7 +- .../business/src/sequence/contact-schedule.ts | 57 ---- packages/business/src/sequence/service.ts | 117 +++++++++ packages/business/src/trigger/service.ts | 81 ++++-- packages/business/src/webhook/service.ts | 3 +- pnpm-lock.yaml | 3 + 55 files changed, 1191 insertions(+), 769 deletions(-) create mode 100644 apps/builder/__tests__/automated-response-type-scope.test.ts delete mode 100644 apps/builder/__tests__/list-broadcast-audience.test.ts create mode 100644 packages/business/__tests__/broadcast-service-audience.test.ts rename apps/builder/src/features/contact-sequences/utils/calculate-next-run-at.ts => packages/business/src/sequence/contact-schedule.ts (92%) diff --git a/apps/builder/__tests__/ai-agents-public-api.test.ts b/apps/builder/__tests__/ai-agents-public-api.test.ts index 3f4d9f1c9e..19017d54c4 100644 --- a/apps/builder/__tests__/ai-agents-public-api.test.ts +++ b/apps/builder/__tests__/ai-agents-public-api.test.ts @@ -50,6 +50,7 @@ const aiAgentService = { listAIAgents: vi.fn(), findBy: vi.fn(), create: vi.fn(), + createAndReturn: vi.fn(), updateAIAgent: vi.fn(), delete: vi.fn(), } @@ -145,31 +146,24 @@ describe("GET /v1/ai-agents/{id}", () => { describe("POST /v1/ai-agents", () => { const procedure = findProcedure("POST", "/v1/ai-agents") - test("delegates to aiAgentService.create then re-fetches by the created id", async () => { - // Regression test: `create` returns the created id and the handler - // re-fetches by that id — not by `name`, which has no unique - // constraint and could match a pre-existing row on a duplicate name. - aiAgentService.create.mockResolvedValueOnce("agent-1") - aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) + test("delegates to aiAgentService.createAndReturn", async () => { + // Regression test: the handler returns the service's full created row + // instead of re-fetching by `name`, which has no unique constraint and + // could match a pre-existing row on a duplicate name. + aiAgentService.createAndReturn.mockResolvedValueOnce({ id: "agent-1" }) await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, input: { name: "Support agent" }, }) - expect(aiAgentService.create).toHaveBeenCalledWith("workspace-1", { + expect(aiAgentService.createAndReturn).toHaveBeenCalledWith("workspace-1", { name: "Support agent", }) - expect(aiAgentService.findBy).toHaveBeenCalledWith({ - where: { id: "agent-1", workspaceId: "workspace-1" }, - }) }) test("two creates with the same name return distinct ids", async () => { - aiAgentService.create - .mockResolvedValueOnce("agent-1") - .mockResolvedValueOnce("agent-2") - aiAgentService.findBy + aiAgentService.createAndReturn .mockResolvedValueOnce({ id: "agent-1", name: "Support agent" }) .mockResolvedValueOnce({ id: "agent-2", name: "Support agent" }) @@ -189,9 +183,8 @@ describe("POST /v1/ai-agents", () => { describe("PUT /v1/ai-agents/{id}", () => { const procedure = findProcedure("PUT", "/v1/ai-agents/{id}") - test("delegates to aiAgentService.updateAIAgent then re-fetches via findBy", async () => { - aiAgentService.updateAIAgent.mockResolvedValueOnce(undefined) - aiAgentService.findBy.mockResolvedValueOnce({ id: "agent-1" }) + test("delegates to aiAgentService.updateAIAgent and returns its result", async () => { + aiAgentService.updateAIAgent.mockResolvedValueOnce({ id: "agent-1" }) await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, diff --git a/apps/builder/__tests__/automated-response-type-scope.test.ts b/apps/builder/__tests__/automated-response-type-scope.test.ts new file mode 100644 index 0000000000..917fcd1866 --- /dev/null +++ b/apps/builder/__tests__/automated-response-type-scope.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const mockUpdate = vi.fn() +const mockFindOrFail = vi.fn() +const mockSetStatus = vi.fn() +const mockDeleteMany = vi.fn() +const mockReturnValidationErrors = vi.fn((_schema, errors) => errors) + +vi.mock("@chatbotx.io/business", () => ({ + automatedResponseService: { + update: (...args: unknown[]) => mockUpdate(...args), + findOrFail: (...args: unknown[]) => mockFindOrFail(...args), + setStatus: (...args: unknown[]) => mockSetStatus(...args), + deleteMany: (...args: unknown[]) => mockDeleteMany(...args), + }, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + automatedResponseTypes: {}, +})) + +vi.mock("next-safe-action", () => ({ + returnValidationErrors: mockReturnValidationErrors, +})) + +vi.mock("@/lib/errors/validation-exception", () => ({ + isValidationException: () => false, +})) + +vi.mock("@/features/common/schema", () => ({ + workspaceIdrequestParams: [], + bulkUpdateIdsRequest: {}, +})) + +vi.mock("@/lib/safe-action", () => ({ + workspaceActionClient: { + bindArgsSchemas: () => ({ + inputSchema: () => ({ action: (fn: unknown) => fn }), + }), + }, +})) + +vi.mock("../src/features/automated-response/schema/action", () => ({ + updateAutomatedResponseRequest: {}, +})) + +const { updateAutomatedResponse } = await import( + "../src/features/automated-response/actions/update-automated-response-action" +) +const { enableAutomatedResponse } = await import( + "../src/features/automated-response/actions/enable-automated-response-action" +) +const { deleteAutomatedResponseAction: deleteAutomatedResponseActionUntyped } = + await import( + "../src/features/automated-response/actions/delete-automated-response-action" + ) +const deleteAutomatedResponseAction = + deleteAutomatedResponseActionUntyped as unknown as ( + props: unknown, + ) => Promise + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("automated-response private actions — type scope threading", () => { + test("updateAutomatedResponse passes the caller's type through to the service, not a hardcoded one", async () => { + mockUpdate.mockResolvedValue({ id: "ar-1" }) + + await updateAutomatedResponse( + { workspaceId: "ws-1", id: "ar-1", type: "outbound" }, + { text: "hi", flowId: null, folderId: null, keywords: [{ value: "x" }] }, + ) + + expect(mockUpdate).toHaveBeenCalledWith( + { workspaceId: "ws-1", id: "ar-1", type: "outbound" }, + expect.objectContaining({ text: "hi" }), + ) + }) + + test("enableAutomatedResponse scopes both findOrFail and setStatus by the caller's type", async () => { + mockFindOrFail.mockResolvedValue({ id: "ar-1" }) + mockSetStatus.mockResolvedValue({ id: "ar-1" }) + + await enableAutomatedResponse( + { workspaceId: "ws-1", id: "ar-1", type: "outbound" }, + { status: true }, + ) + + expect(mockFindOrFail).toHaveBeenCalledWith({ + workspaceId: "ws-1", + id: "ar-1", + type: "outbound", + }) + expect(mockSetStatus).toHaveBeenCalledWith( + { workspaceId: "ws-1", id: "ar-1", type: "outbound" }, + true, + ) + }) + + test("deleteAutomatedResponseAction scopes the bulk delete by the caller's type", async () => { + mockDeleteMany.mockResolvedValue(undefined) + + await deleteAutomatedResponseAction({ + bindArgsParsedInputs: ["ws-1", "outbound"], + parsedInput: { ids: ["ar-1", "ar-2"] }, + } as never) + + expect(mockDeleteMany).toHaveBeenCalledWith( + "ws-1", + ["ar-1", "ar-2"], + "outbound", + ) + }) +}) diff --git a/apps/builder/__tests__/broadcasts-public-scope.test.ts b/apps/builder/__tests__/broadcasts-public-scope.test.ts index f1aae52289..2bb4c71226 100644 --- a/apps/builder/__tests__/broadcasts-public-scope.test.ts +++ b/apps/builder/__tests__/broadcasts-public-scope.test.ts @@ -21,6 +21,7 @@ vi.mock("@chatbotx.io/business", () => ({ isWorkspaceScheduledForDeletion, userQuotaService: { getAccessState }, quotaEnforcementService: { isAtLimit }, + broadcastService: { findByIdOrName: vi.fn() }, })) vi.mock("@/lib/log", () => ({ @@ -49,7 +50,6 @@ vi.mock("@/middlewares/auth", () => ({ // test exercises, but the import chain must not try to open a connection. vi.mock("../src/features/broadcasts/queries", () => ({ listBroadcasts: vi.fn(), - publicGetBroadcast: vi.fn(), listBroadcastAudience: vi.fn(), })) diff --git a/apps/builder/__tests__/create-sequence.action.test.ts b/apps/builder/__tests__/create-sequence.action.test.ts index d61d56f7f5..dddcba18ce 100644 --- a/apps/builder/__tests__/create-sequence.action.test.ts +++ b/apps/builder/__tests__/create-sequence.action.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business", () => ({ +vi.mock("@chatbotx.io/business/sequence", () => ({ sequenceService: { create: mockCreate }, })) diff --git a/apps/builder/__tests__/delete-sequence-step.action.test.ts b/apps/builder/__tests__/delete-sequence-step.action.test.ts index ee65f01739..2298e60a01 100644 --- a/apps/builder/__tests__/delete-sequence-step.action.test.ts +++ b/apps/builder/__tests__/delete-sequence-step.action.test.ts @@ -2,14 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { - mockAssertOwned, - mockDeleteStep, - mockRecalculateAllContactsInSequence, -} = vi.hoisted(() => ({ +const { mockAssertOwned, mockDeleteStep } = vi.hoisted(() => ({ mockAssertOwned: vi.fn().mockResolvedValue(undefined), mockDeleteStep: vi.fn().mockResolvedValue(undefined), - mockRecalculateAllContactsInSequence: vi.fn().mockResolvedValue(undefined), })) vi.mock("@/lib/safe-action", () => { @@ -20,7 +15,7 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business", () => ({ +vi.mock("@chatbotx.io/business/sequence", () => ({ sequenceService: { assertOwned: mockAssertOwned, deleteStep: mockDeleteStep, @@ -31,10 +26,6 @@ vi.mock("@/features/common/schema", () => ({ workspaceIdrequestParams: [], })) -vi.mock("@/features/contact-sequences/utils/calculate-next-run-at", () => ({ - recalculateAllContactsInSequence: mockRecalculateAllContactsInSequence, -})) - const { deleteSequenceStepAction } = await import( "../src/features/sequences/actions/delete-sequence-step.action" ) @@ -55,10 +46,9 @@ describe("deleteSequenceStepAction", () => { vi.clearAllMocks() mockAssertOwned.mockResolvedValue(undefined) mockDeleteStep.mockResolvedValue(undefined) - mockRecalculateAllContactsInSequence.mockResolvedValue(undefined) }) - test("validates sequence ownership, deletes step, and recalculates contacts", async () => { + test("validates sequence ownership and delegates to sequenceService.deleteStep", async () => { const result = await callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, @@ -72,14 +62,10 @@ describe("deleteSequenceStepAction", () => { workspaceId: WS, stepId: STEP_ID, }) - expect(mockRecalculateAllContactsInSequence).toHaveBeenCalledWith( - SEQ_ID, - WS, - ) expect(result).toEqual({ success: true }) }) - test("propagates a sequence-not-found error and never deletes or recalculates", async () => { + test("propagates a sequence-not-found error and never deletes", async () => { mockAssertOwned.mockRejectedValue(new Error("Sequence not found")) await expect( @@ -90,10 +76,9 @@ describe("deleteSequenceStepAction", () => { ).rejects.toThrow("Sequence not found") expect(mockDeleteStep).not.toHaveBeenCalled() - expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() }) - test("propagates a step-not-found error and never recalculates", async () => { + test("propagates a step-not-found error", async () => { mockDeleteStep.mockRejectedValue(new Error("Step not found")) await expect( @@ -102,8 +87,6 @@ describe("deleteSequenceStepAction", () => { parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID }, }), ).rejects.toThrow("Step not found") - - expect(mockRecalculateAllContactsInSequence).not.toHaveBeenCalled() }) test("propagates an unauthorized cross-workspace error", async () => { diff --git a/apps/builder/__tests__/delete-sequence.action.test.ts b/apps/builder/__tests__/delete-sequence.action.test.ts index 60f3a62e4b..b61d143146 100644 --- a/apps/builder/__tests__/delete-sequence.action.test.ts +++ b/apps/builder/__tests__/delete-sequence.action.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business", () => ({ +vi.mock("@chatbotx.io/business/sequence", () => ({ sequenceService: { delete: mockDelete }, })) diff --git a/apps/builder/__tests__/keywords-public-api.test.ts b/apps/builder/__tests__/keywords-public-api.test.ts index cfb4e013d7..f1c3ccdd6a 100644 --- a/apps/builder/__tests__/keywords-public-api.test.ts +++ b/apps/builder/__tests__/keywords-public-api.test.ts @@ -142,7 +142,7 @@ describe("GET /v1/keywords/{id}", () => { await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, - input: { id: "keyword-1" }, + input: { id: "keyword-1", type: "inbound" }, }) expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ @@ -151,6 +151,23 @@ describe("GET /v1/keywords/{id}", () => { type: "inbound", }) }) + + test("threads type=outbound through so an outbound id doesn't 404", async () => { + automatedResponseService.findOrFail.mockResolvedValueOnce({ + id: "keyword-2", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-2", type: "outbound" }, + }) + + expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "keyword-2", + type: "outbound", + }) + }) }) describe("POST /v1/keywords", () => { @@ -176,29 +193,39 @@ describe("POST /v1/keywords", () => { describe("PUT /v1/keywords/{id}", () => { const procedure = findProcedure("PUT", "/v1/keywords/{id}") - test("verifies existence then delegates to automatedResponseService.update", async () => { - automatedResponseService.findOrFail.mockResolvedValueOnce({ - id: "keyword-1", - }) + test("delegates to automatedResponseService.update without a redundant pre-check", async () => { automatedResponseService.update.mockResolvedValueOnce({ id: "keyword-1", }) await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, - input: { id: "keyword-1", keywords: ["hello"] }, + input: { id: "keyword-1", type: "inbound", keywords: ["hello"] }, }) - expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ - workspaceId: "workspace-1", - id: "keyword-1", - type: "inbound", - }) + // `update` throws not-found itself now — no separate existence check. + expect(automatedResponseService.findOrFail).not.toHaveBeenCalled() expect(automatedResponseService.update).toHaveBeenCalledWith( { workspaceId: "workspace-1", id: "keyword-1", type: "inbound" }, { keywords: [{ value: "hello" }] }, ) }) + + test("threads type=outbound through to the service call", async () => { + automatedResponseService.update.mockResolvedValueOnce({ + id: "keyword-2", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-2", type: "outbound", keywords: ["hi"] }, + }) + + expect(automatedResponseService.update).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "keyword-2", type: "outbound" }, + { keywords: [{ value: "hi" }] }, + ) + }) }) describe("PATCH /v1/keywords/{id}/status", () => { @@ -214,7 +241,7 @@ describe("PATCH /v1/keywords/{id}/status", () => { await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, - input: { id: "keyword-1", status: false }, + input: { id: "keyword-1", status: false, type: "inbound" }, }) expect(automatedResponseService.setStatus).toHaveBeenCalledWith( @@ -222,6 +249,30 @@ describe("PATCH /v1/keywords/{id}/status", () => { false, ) }) + + test("threads type=outbound through to findOrFail and setStatus", async () => { + automatedResponseService.findOrFail.mockResolvedValueOnce({ + id: "keyword-2", + }) + automatedResponseService.setStatus.mockResolvedValueOnce({ + id: "keyword-2", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-2", status: true, type: "outbound" }, + }) + + expect(automatedResponseService.findOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "keyword-2", + type: "outbound", + }) + expect(automatedResponseService.setStatus).toHaveBeenCalledWith( + { workspaceId: "workspace-1", id: "keyword-2", type: "outbound" }, + true, + ) + }) }) describe("DELETE /v1/keywords/{id}", () => { @@ -232,14 +283,28 @@ describe("DELETE /v1/keywords/{id}", () => { await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, - input: { id: "keyword-1" }, + input: { id: "keyword-1", type: "inbound" }, }) expect(automatedResponseService.deleteMany).toHaveBeenCalledWith( "workspace-1", ["keyword-1"], - undefined, "inbound", ) }) + + test("threads type=outbound through to deleteMany", async () => { + automatedResponseService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "keyword-2", type: "outbound" }, + }) + + expect(automatedResponseService.deleteMany).toHaveBeenCalledWith( + "workspace-1", + ["keyword-2"], + "outbound", + ) + }) }) diff --git a/apps/builder/__tests__/list-broadcast-audience.test.ts b/apps/builder/__tests__/list-broadcast-audience.test.ts deleted file mode 100644 index b2e7cbe4e7..0000000000 --- a/apps/builder/__tests__/list-broadcast-audience.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, test, vi } from "vitest" - -const { - mockFindIdIfActive, - mockListAudience, - mockCountAudience, - mockNotFoundException, -} = vi.hoisted(() => ({ - mockFindIdIfActive: vi.fn(), - mockListAudience: vi.fn().mockResolvedValue([]), - mockCountAudience: vi.fn().mockResolvedValue(0), - mockNotFoundException: vi.fn((message: string) => new Error(message)), -})) - -vi.mock("@chatbotx.io/database/repositories", () => ({ - broadcastRepository: { - findIdIfActive: mockFindIdIfActive, - listAudience: mockListAudience, - countAudience: mockCountAudience, - }, -})) - -vi.mock("@chatbotx.io/database/utils", () => ({ - getPaginationWithDefaults: (input: { page?: number; perPage?: number }) => ({ - limit: input.perPage ?? 10, - offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), - }), - likeContains: (value: string) => value, - parseOrderByAsObject: () => undefined, -})) - -vi.mock("@chatbotx.io/business/errors", () => ({ - notFoundException: mockNotFoundException, -})) - -vi.mock("@/lib/auth/utils", () => ({ - assertCurrentUserCanAccessChatbot: vi.fn().mockResolvedValue(undefined), -})) - -const { listBroadcastAudience } = await import( - "../src/features/broadcasts/queries/index" -) - -describe("listBroadcastAudience deletedAt gate", () => { - beforeEach(() => { - vi.clearAllMocks() - mockListAudience.mockResolvedValue([]) - mockCountAudience.mockResolvedValue(0) - }) - - test("looks up the broadcast scoped to workspaceId + id + deletedAt IS NULL before listing recipients", async () => { - mockFindIdIfActive.mockResolvedValue({ id: "b-1" }) - - await listBroadcastAudience({ - broadcastId: "b-1", - workspaceId: "ws-1", - page: 1, - perPage: 10, - }) - - expect(mockFindIdIfActive).toHaveBeenCalledWith({ - id: "b-1", - workspaceId: "ws-1", - }) - expect(mockListAudience).toHaveBeenCalled() - }) - - test("throws not-found for a soft-deleted broadcast and never queries recipients", async () => { - mockFindIdIfActive.mockResolvedValue(undefined) - - await expect( - listBroadcastAudience({ - broadcastId: "b-deleted", - workspaceId: "ws-1", - page: 1, - perPage: 10, - }), - ).rejects.toThrow("Broadcast not found") - - expect(mockNotFoundException).toHaveBeenCalledWith("Broadcast not found") - expect(mockListAudience).not.toHaveBeenCalled() - expect(mockCountAudience).not.toHaveBeenCalled() - }) - - test("throws not-found when the broadcast exists but belongs to a different workspace", async () => { - mockFindIdIfActive.mockResolvedValue(undefined) - - await expect( - listBroadcastAudience({ - broadcastId: "b-1", - workspaceId: "ws-foreign", - page: 1, - perPage: 10, - }), - ).rejects.toThrow("Broadcast not found") - - expect(mockFindIdIfActive).toHaveBeenCalledWith({ - id: "b-1", - workspaceId: "ws-foreign", - }) - }) -}) diff --git a/apps/builder/__tests__/public-list-queries-no-session.test.ts b/apps/builder/__tests__/public-list-queries-no-session.test.ts index 99946635c6..f6339906d1 100644 --- a/apps/builder/__tests__/public-list-queries-no-session.test.ts +++ b/apps/builder/__tests__/public-list-queries-no-session.test.ts @@ -20,6 +20,10 @@ const mocks = vi.hoisted(() => ({ broadcastCount: vi.fn().mockResolvedValue(0), sequenceListWithCounts: vi.fn().mockResolvedValue([]), sequenceCount: vi.fn().mockResolvedValue(0), + sequenceFindWithSteps: vi.fn().mockResolvedValue({ + id: "seq-1", + sequenceSteps: [], + }), })) vi.mock("@/lib/auth/utils", () => ({ @@ -63,6 +67,36 @@ vi.mock("@chatbotx.io/utils/error-log", () => ({ vi.mock("@chatbotx.io/business", () => ({ inboxTeamService: { listByWorkspace: mocks.listByWorkspace }, conversationService: { findManyQuery: mocks.findManyQuery }, + broadcastService: { + list: async (input: { page?: number; perPage?: number }) => { + const pagination = { + limit: input.perPage ?? 10, + offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), + } + const [data, total] = await Promise.all([ + mocks.broadcastListWithRelations(input), + mocks.broadcastCount(input), + ]) + return { data, pageCount: Math.ceil(total / pagination.limit) } + }, + }, +})) + +vi.mock("@chatbotx.io/business/sequence", () => ({ + sequenceService: { + findWithSteps: mocks.sequenceFindWithSteps, + list: async (input: { page?: number; perPage?: number }) => { + const pagination = { + limit: input.perPage ?? 10, + offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), + } + const [data, total] = await Promise.all([ + mocks.sequenceListWithCounts(input), + mocks.sequenceCount(input), + ]) + return { data, pageCount: Math.ceil(total / pagination.limit) } + }, + }, })) vi.mock("@chatbotx.io/business/ads-conversion/channel-fields", () => ({ @@ -84,6 +118,7 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ sequenceRepository: { listWithCounts: mocks.sequenceListWithCounts, count: mocks.sequenceCount, + findWithSteps: mocks.sequenceFindWithSteps, }, })) @@ -105,6 +140,10 @@ beforeEach(() => { mocks.broadcastCount.mockResolvedValue(0) mocks.sequenceListWithCounts.mockResolvedValue([]) mocks.sequenceCount.mockResolvedValue(0) + mocks.sequenceFindWithSteps.mockResolvedValue({ + id: "seq-1", + sequenceSteps: [], + }) }) describe("public list queries never depend on a session", () => { @@ -133,6 +172,14 @@ describe("public list queries never depend on a session", () => { expect(mocks.assertCurrentUserCanAccessChatbot).not.toHaveBeenCalled() }) + test("sequenceService.findWithSteps resolves without a session", async () => { + const { sequenceService } = await import("@chatbotx.io/business/sequence") + await expect( + sequenceService.findWithSteps({ workspaceId: "ws-1", id: "seq-1" }), + ).resolves.toBeDefined() + expect(mocks.assertCurrentUserCanAccessChatbot).not.toHaveBeenCalled() + }) + test("listErrorLogs resolves without a session", async () => { const { listErrorLogs } = await import("../src/features/error-logs/queries") await expect(listErrorLogs({ workspaceId: "ws-1" })).resolves.toBeDefined() diff --git a/apps/builder/__tests__/triggers-public-api.test.ts b/apps/builder/__tests__/triggers-public-api.test.ts index 99d50d5ea8..f221ae032f 100644 --- a/apps/builder/__tests__/triggers-public-api.test.ts +++ b/apps/builder/__tests__/triggers-public-api.test.ts @@ -60,13 +60,6 @@ vi.mock("@chatbotx.io/business/errors", () => ({ notFoundException: (message: string) => new Error(message), })) -const conditionRepository = { - listByTriggerIds: vi.fn(), -} -vi.mock("@chatbotx.io/database/repositories", () => ({ - conditionRepository, -})) - vi.mock("@chatbotx.io/database/schema", () => { const schema = { pick: vi.fn(() => schema), @@ -190,13 +183,11 @@ describe("POST /v1/triggers", () => { describe("PUT /v1/triggers/{id}", () => { const procedure = findProcedure("PUT", "/v1/triggers/{id}") - test("delegates to triggerService.updateWithConditions and returns the service's result plus fresh conditions", async () => { + test("delegates to triggerService.updateWithConditions and returns its trigger plus conditions", async () => { triggerService.updateWithConditions.mockResolvedValueOnce({ - id: "trigger-1", + trigger: { id: "trigger-1" }, + conditions: [{ id: "c1", type: "newContact" }], }) - conditionRepository.listByTriggerIds.mockResolvedValueOnce([ - { id: "c1", type: "newContact" }, - ]) const result = await procedure.handler?.({ context: { workspace: { id: "workspace-1" } }, @@ -215,11 +206,9 @@ describe("PUT /v1/triggers/{id}", () => { actions: [{ type: "sendFlow" }], conditions: [{ type: "newContact" }], }) - // No redundant re-read of the trigger row itself — only conditions. + // No redundant re-read — the service already returns conditions from + // inside its own transaction. expect(triggerService.findWithConditions).not.toHaveBeenCalled() - expect(conditionRepository.listByTriggerIds).toHaveBeenCalledWith([ - "trigger-1", - ]) expect(result.conditions).toEqual([{ id: "c1", type: "newContact" }]) }) @@ -238,9 +227,8 @@ describe("PUT /v1/triggers/{id}", () => { describe("PATCH /v1/triggers/{id}/settings", () => { const procedure = findProcedure("PATCH", "/v1/triggers/{id}/settings") - test("delegates to triggerService.updateSettings and returns the updated resource", async () => { - triggerService.updateSettings.mockResolvedValueOnce(undefined) - triggerService.findWithConditions.mockResolvedValueOnce({ + test("delegates to triggerService.updateSettings and returns its result", async () => { + triggerService.updateSettings.mockResolvedValueOnce({ id: "trigger-1", active: false, conditions: [], @@ -257,16 +245,13 @@ describe("PATCH /v1/triggers/{id}/settings", () => { id: "trigger-1", active: false, }) - expect(triggerService.findWithConditions).toHaveBeenCalledWith({ - id: "trigger-1", - workspaceId: "workspace-1", - }) expect(result.active).toBe(false) }) - test("throws not found when the trigger no longer exists after updateSettings", async () => { - triggerService.updateSettings.mockResolvedValueOnce(undefined) - triggerService.findWithConditions.mockResolvedValueOnce(null) + test("propagates a not-found error from updateSettings", async () => { + triggerService.updateSettings.mockRejectedValueOnce( + new Error("Trigger not found"), + ) await expect( procedure.handler?.({ diff --git a/apps/builder/__tests__/upsert-sequence-step.action.test.ts b/apps/builder/__tests__/upsert-sequence-step.action.test.ts index 5c32230e6e..476a0205da 100644 --- a/apps/builder/__tests__/upsert-sequence-step.action.test.ts +++ b/apps/builder/__tests__/upsert-sequence-step.action.test.ts @@ -2,18 +2,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest" -const { - mockAssertOwned, - mockCreateStep, - mockUpdateStep, - mockHandleStepCreationImpact, - mockHandleStepUpdateImpact, -} = vi.hoisted(() => ({ +const { mockAssertOwned, mockUpsertStep } = vi.hoisted(() => ({ mockAssertOwned: vi.fn().mockResolvedValue(undefined), - mockCreateStep: vi.fn(), - mockUpdateStep: vi.fn(), - mockHandleStepCreationImpact: vi.fn().mockResolvedValue(undefined), - mockHandleStepUpdateImpact: vi.fn().mockResolvedValue(undefined), + mockUpsertStep: vi.fn(), })) vi.mock("@/lib/safe-action", () => { @@ -24,11 +15,10 @@ vi.mock("@/lib/safe-action", () => { return { workspaceActionClient: chain } }) -vi.mock("@chatbotx.io/business", () => ({ +vi.mock("@chatbotx.io/business/sequence", () => ({ sequenceService: { assertOwned: mockAssertOwned, - createStep: mockCreateStep, - updateStep: mockUpdateStep, + upsertStep: mockUpsertStep, }, })) @@ -36,11 +26,6 @@ vi.mock("@/features/common/schema", () => ({ workspaceIdrequestParams: [], })) -vi.mock("@/features/contact-sequences/utils/calculate-next-run-at", () => ({ - handleStepCreationImpact: mockHandleStepCreationImpact, - handleStepUpdateImpact: mockHandleStepUpdateImpact, -})) - vi.mock("@/features/sequences/schema/action", () => ({ upsertSequenceStepRequest: {}, })) @@ -73,196 +58,72 @@ describe("upsertSequenceStepAction", () => { beforeEach(() => { vi.clearAllMocks() mockAssertOwned.mockResolvedValue(undefined) - mockCreateStep.mockResolvedValue({ id: "new-step-id" }) - mockUpdateStep.mockResolvedValue({ - previousOrder: 1, - step: { id: STEP_ID }, - }) + mockUpsertStep.mockResolvedValue({ stepId: STEP_ID }) }) - describe("create path (no stepId)", () => { - test("validates ownership, creates the step, and recalculates for affected contacts", async () => { - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 0 }, - }) + test("validates ownership before delegating to sequenceService.upsertStep", async () => { + const parsedInput = { sequenceId: SEQ_ID, order: 0 } - expect(mockAssertOwned).toHaveBeenCalledWith({ - workspaceId: WS, - sequenceId: SEQ_ID, - }) - expect(mockCreateStep).toHaveBeenCalledWith({ - workspaceId: WS, - sequenceId: SEQ_ID, - data: { sequenceId: SEQ_ID, order: 0 }, - }) - expect(mockHandleStepCreationImpact).toHaveBeenCalledWith(SEQ_ID, WS, 0) - expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() - expect(result).toEqual({ stepId: "new-step-id" }) + const result = await callAction({ + bindArgsParsedInputs: [WS], + parsedInput, }) - test("does not call updateStep on the create path", async () => { - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 0 }, - }) - - expect(mockUpdateStep).not.toHaveBeenCalled() + expect(mockAssertOwned).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, }) - }) - - describe("update path (stepId provided)", () => { - test("validates ownership, updates the step, and returns its id", async () => { - const result = await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 1, - delayDays: 2, - }, - }) - - expect(mockUpdateStep).toHaveBeenCalledWith({ - workspaceId: WS, - stepId: STEP_ID, - data: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 1, - delayDays: 2, - }, - }) - expect(result).toEqual({ stepId: STEP_ID }) + expect(mockUpsertStep).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, + stepId: undefined, + data: parsedInput, }) + expect(result).toEqual({ stepId: STEP_ID }) + }) - test("calls handleStepUpdateImpact when delayDays changes", async () => { - mockUpdateStep.mockResolvedValue({ - previousOrder: 1, - step: { id: STEP_ID }, - }) - - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 1, - delayDays: 3, - }, - }) - - expect(mockHandleStepUpdateImpact).toHaveBeenCalledWith( - SEQ_ID, - WS, - STEP_ID, - 1, - ) - expect(mockHandleStepCreationImpact).not.toHaveBeenCalled() + test("passes stepId through on the update path", async () => { + const parsedInput = { + stepId: STEP_ID, + sequenceId: SEQ_ID, + order: 1, + delayDays: 2, + } + + await callAction({ + bindArgsParsedInputs: [WS], + parsedInput, }) - test("calls handleStepUpdateImpact when isActive changes", async () => { - mockUpdateStep.mockResolvedValue({ - previousOrder: 0, - step: { id: STEP_ID }, - }) - - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 0, - isActive: false, - }, - }) - - expect(mockHandleStepUpdateImpact).toHaveBeenCalledTimes(1) + expect(mockUpsertStep).toHaveBeenCalledWith({ + workspaceId: WS, + sequenceId: SEQ_ID, + stepId: STEP_ID, + data: parsedInput, }) + }) - test("calls handleStepUpdateImpact when order changed from previousOrder", async () => { - mockUpdateStep.mockResolvedValue({ - previousOrder: 5, - step: { id: STEP_ID }, - }) + test("propagates a sequence-not-found error before upsertStep is called", async () => { + mockAssertOwned.mockRejectedValue(new Error("Sequence not found")) - await callAction({ + await expect( + callAction({ bindArgsParsedInputs: [WS], - parsedInput: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 1, - }, - }) - - expect(mockHandleStepUpdateImpact).toHaveBeenCalledTimes(1) - }) - - test("does not call handleStepUpdateImpact when only flowId changes and order is unchanged", async () => { - mockUpdateStep.mockResolvedValue({ - previousOrder: 1, - step: { id: STEP_ID }, - }) + parsedInput: { sequenceId: SEQ_ID, order: 0 }, + }), + ).rejects.toThrow("Sequence not found") - await callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { - stepId: STEP_ID, - sequenceId: SEQ_ID, - order: 1, - flowId: "flow-abc", - }, - }) + expect(mockUpsertStep).not.toHaveBeenCalled() + }) - expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() - }) + test("propagates an upsertStep error", async () => { + mockUpsertStep.mockRejectedValue(new Error("Step not found")) - test("does not call createStep on the update path", async () => { - await callAction({ + await expect( + callAction({ bindArgsParsedInputs: [WS], parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, - }) - - expect(mockCreateStep).not.toHaveBeenCalled() - }) - }) - - describe("errors", () => { - test("propagates a sequence-not-found error on the create path", async () => { - mockAssertOwned.mockRejectedValue(new Error("Sequence not found")) - - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { sequenceId: SEQ_ID, order: 0 }, - }), - ).rejects.toThrow("Sequence not found") - }) - - test("propagates a step-not-found error on the update path", async () => { - mockUpdateStep.mockRejectedValue(new Error("Step not found")) - - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, - }), - ).rejects.toThrow("Step not found") - - expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() - }) - - test("propagates an unauthorized cross-workspace error on the update path", async () => { - mockUpdateStep.mockRejectedValue( - new Error("Unauthorized: Step does not belong to this workspace"), - ) - - await expect( - callAction({ - bindArgsParsedInputs: [WS], - parsedInput: { stepId: STEP_ID, sequenceId: SEQ_ID, order: 0 }, - }), - ).rejects.toThrow("Unauthorized: Step does not belong to this workspace") - }) + }), + ).rejects.toThrow("Step not found") }) }) diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx index e544aef9b0..4b967b1278 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx @@ -6,6 +6,8 @@ import { notFound } from "next/navigation" import type { FlowVersionResource } from "@/features/flow-versions/schema/resource" import { buildSmartDelayNodeStats } from "@/features/flows/analytics/smart-delay-node-stats" import { FlowAnalytics } from "@/features/flows/flow-analytics" +import { FlowStoreProvider } from "@/features/flows/provider/flow-store-context" +import { FlowTemplateStoreProvider } from "@/features/flows/react-flow/stores/flow-template-store-provider" import { withWorkspaceIdAndIdSchema } from "@/features/workspaces/schema/resource" import { requireWorkspacePermission } from "@/lib/auth/require-workspace-permission" @@ -55,12 +57,16 @@ export default async function FlowAnalyticsPage({ return (
- + + + + +
) } diff --git a/apps/builder/src/app/space/[workspaceId]/automated-responses/[id]/edit/page.tsx b/apps/builder/src/app/space/[workspaceId]/automated-responses/[id]/edit/page.tsx index b2a0d74449..21cb8b9f8c 100644 --- a/apps/builder/src/app/space/[workspaceId]/automated-responses/[id]/edit/page.tsx +++ b/apps/builder/src/app/space/[workspaceId]/automated-responses/[id]/edit/page.tsx @@ -14,7 +14,11 @@ export default async function EditAutomatedResponePage({ } const { workspaceId, id } = data - const automatedResponse = await findAutomatedResponse({ workspaceId, id }) + const automatedResponse = await findAutomatedResponse({ + workspaceId, + id, + type: "inbound", + }) if (!automatedResponse) { return notFound() } @@ -22,6 +26,7 @@ export default async function EditAutomatedResponePage({ return ( ) diff --git a/apps/builder/src/app/space/[workspaceId]/page-automated-responses/[id]/edit/page.tsx b/apps/builder/src/app/space/[workspaceId]/page-automated-responses/[id]/edit/page.tsx index 02c5b50742..0a452f2731 100644 --- a/apps/builder/src/app/space/[workspaceId]/page-automated-responses/[id]/edit/page.tsx +++ b/apps/builder/src/app/space/[workspaceId]/page-automated-responses/[id]/edit/page.tsx @@ -14,7 +14,11 @@ export default async function EditPageAutomatedResponePage({ } const { workspaceId, id } = data - const automatedResponse = await findAutomatedResponse({ workspaceId, id }) + const automatedResponse = await findAutomatedResponse({ + workspaceId, + id, + type: "outbound", + }) if (!automatedResponse) { return notFound() } @@ -22,6 +26,7 @@ export default async function EditPageAutomatedResponePage({ return ( ) diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 9fce466209..202eacf62d 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -68,16 +68,10 @@ export const aiAgentsPublicRouter = { .input(createAIAgentRequest) .output(aiAgentResourceSchema) .errors(possibleErrorsOnCreatingResource) - .handler(async ({ context, input }) => { - const id = await aiAgentService.create(context.workspace.id, input) - const created = await aiAgentService.findBy({ - where: { id, workspaceId: context.workspace.id }, - }) - if (!created) { - throw notFoundException("AI agent not found") - } - return created - }), + .handler( + async ({ context, input }) => + await aiAgentService.createAndReturn(context.workspace.id, input), + ), update: workspaceTokenAuthAPI .route({ @@ -91,17 +85,10 @@ export const aiAgentsPublicRouter = { .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, ...data } = input - await aiAgentService.updateAIAgent( + return await aiAgentService.updateAIAgent( { workspaceId: context.workspace.id, id }, data, ) - const updated = await aiAgentService.findBy({ - where: { id, workspaceId: context.workspace.id }, - }) - if (!updated) { - throw notFoundException("AI agent not found") - } - return updated }), delete: workspaceTokenAuthAPI diff --git a/apps/builder/src/features/automated-response/actions/delete-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/delete-automated-response-action.ts index 0b2e17be1d..2a43065d9f 100644 --- a/apps/builder/src/features/automated-response/actions/delete-automated-response-action.ts +++ b/apps/builder/src/features/automated-response/actions/delete-automated-response-action.ts @@ -1,6 +1,7 @@ "use server" import { automatedResponseService } from "@chatbotx.io/business" +import { automatedResponseTypes } from "@chatbotx.io/database/partials" import { bulkUpdateIdsRequest, workspaceIdrequestParams, @@ -8,13 +9,17 @@ import { import { workspaceActionClient } from "@/lib/safe-action" export const deleteAutomatedResponseAction = workspaceActionClient - .bindArgsSchemas(workspaceIdrequestParams) + .bindArgsSchemas([...workspaceIdrequestParams, automatedResponseTypes]) .inputSchema(bulkUpdateIdsRequest) .action(async (props) => { const { - bindArgsParsedInputs: [workspaceId], + bindArgsParsedInputs: [workspaceId, type], parsedInput, } = props - await automatedResponseService.deleteMany(workspaceId, parsedInput.ids) + await automatedResponseService.deleteMany( + workspaceId, + parsedInput.ids, + type, + ) }) diff --git a/apps/builder/src/features/automated-response/actions/enable-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/enable-automated-response-action.ts index 9d2b294485..e3761f7e9a 100644 --- a/apps/builder/src/features/automated-response/actions/enable-automated-response-action.ts +++ b/apps/builder/src/features/automated-response/actions/enable-automated-response-action.ts @@ -1,6 +1,10 @@ "use server" import { automatedResponseService } from "@chatbotx.io/business" +import { + type AutomatedResponseType, + automatedResponseTypes, +} from "@chatbotx.io/database/partials" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" import { workspaceActionClient } from "@/lib/safe-action" @@ -11,24 +15,29 @@ const enableRequest = z.object({ type EnableRequest = z.infer export const enableAutomatedResponseAction = workspaceActionClient - .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) + .bindArgsSchemas([ + zodBigintAsString(), + zodBigintAsString(), + automatedResponseTypes, + ]) .inputSchema(enableRequest) .action(async (props) => { const { - bindArgsParsedInputs: [workspaceId, id], + bindArgsParsedInputs: [workspaceId, id, type], parsedInput, } = props - return await enableAutomatedResponse({ workspaceId, id }, parsedInput) + return await enableAutomatedResponse({ workspaceId, id, type }, parsedInput) }) export const enableAutomatedResponse = async ( - ctx: { workspaceId: string; id: string }, + ctx: { workspaceId: string; id: string; type: AutomatedResponseType }, parsedInput: EnableRequest, ) => { await automatedResponseService.findOrFail({ workspaceId: ctx.workspaceId, id: ctx.id, + type: ctx.type, }) await automatedResponseService.setStatus(ctx, parsedInput.status) } diff --git a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts index 0003cc204d..839df2be2a 100644 --- a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts +++ b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts @@ -4,6 +4,8 @@ import { automatedResponseService, type UpdateAutomatedResponseRequest, } from "@chatbotx.io/business" +import type { AutomatedResponseType } from "@chatbotx.io/database/partials" +import { automatedResponseTypes } from "@chatbotx.io/database/partials" import { zodBigintAsString } from "@chatbotx.io/utils" import { returnValidationErrors } from "next-safe-action" import { isValidationException } from "@/lib/errors/validation-exception" @@ -11,26 +13,25 @@ import { workspaceActionClient } from "@/lib/safe-action" import { updateAutomatedResponseRequest } from "../schema/action" export const updateAutomatedResponseAction = workspaceActionClient - .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) + .bindArgsSchemas([ + zodBigintAsString(), + zodBigintAsString(), + automatedResponseTypes, + ]) .inputSchema(updateAutomatedResponseRequest) .action(async (props) => { const { - bindArgsParsedInputs: [workspaceId, id], + bindArgsParsedInputs: [workspaceId, id, type], parsedInput, } = props - return await updateAutomatedResponse({ workspaceId, id }, parsedInput) + return await updateAutomatedResponse({ workspaceId, id, type }, parsedInput) }) export const updateAutomatedResponse = async ( - ctx: { workspaceId: string; id: string }, + ctx: { workspaceId: string; id: string; type: AutomatedResponseType }, parsedInput: UpdateAutomatedResponseRequest, ) => { - await automatedResponseService.findOrFail({ - workspaceId: ctx.workspaceId, - id: ctx.id, - }) - try { // `text`/`flowId` mutual-exclusion and cross-workspace `flowId` // validation live in `automatedResponseService.update` so every caller diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index 6ec83b0ed6..d2ab06b1bb 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -49,7 +49,12 @@ export const keywordsPublicRouter = { summary: "Get a keyword automation by id", tags: ["Keywords"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString(), + type: automatedResponseTypes.default("inbound"), + }), + ) .output(publicKeywordResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -57,7 +62,7 @@ export const keywordsPublicRouter = { await automatedResponseService.findOrFail({ workspaceId: context.workspace.id, id: input.id, - type: "inbound", + type: input.type, }), ), @@ -95,6 +100,7 @@ export const keywordsPublicRouter = { .input( z.object({ id: zodBigintAsString(), + type: automatedResponseTypes.default("inbound"), keywords: z.array(z.string().min(1).max(255)).min(1).optional(), text: z.string().min(1).nullish(), flowId: zodBigintAsString().nullish(), @@ -104,14 +110,9 @@ export const keywordsPublicRouter = { .output(publicKeywordResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { - const { id, keywords, ...rest } = input - await automatedResponseService.findOrFail({ - workspaceId: context.workspace.id, - id, - type: "inbound", - }) + const { id, type, keywords, ...rest } = input return await automatedResponseService.update( - { workspaceId: context.workspace.id, id, type: "inbound" }, + { workspaceId: context.workspace.id, id, type }, { ...rest, keywords: keywords?.map((value) => ({ value })), @@ -126,17 +127,23 @@ export const keywordsPublicRouter = { summary: "Enable or disable a keyword automation", tags: ["Keywords"], }) - .input(z.object({ id: zodBigintAsString(), status: z.boolean() })) + .input( + z.object({ + id: zodBigintAsString(), + status: z.boolean(), + type: automatedResponseTypes.default("inbound"), + }), + ) .output(publicKeywordResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { await automatedResponseService.findOrFail({ workspaceId: context.workspace.id, id: input.id, - type: "inbound", + type: input.type, }) return await automatedResponseService.setStatus( - { workspaceId: context.workspace.id, id: input.id, type: "inbound" }, + { workspaceId: context.workspace.id, id: input.id, type: input.type }, input.status, ) }), @@ -149,14 +156,18 @@ export const keywordsPublicRouter = { successStatus: 204, tags: ["Keywords"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString(), + type: automatedResponseTypes.default("inbound"), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await automatedResponseService.deleteMany( context.workspace.id, [input.id], - undefined, - "inbound", + input.type, ) }), } diff --git a/apps/builder/src/features/automated-response/automated-response-table-toolbar-actions.tsx b/apps/builder/src/features/automated-response/automated-response-table-toolbar-actions.tsx index 25c50533e7..6aecc2638c 100644 --- a/apps/builder/src/features/automated-response/automated-response-table-toolbar-actions.tsx +++ b/apps/builder/src/features/automated-response/automated-response-table-toolbar-actions.tsx @@ -1,6 +1,9 @@ "use client" -import type { FolderType } from "@chatbotx.io/database/partials" +import type { + AutomatedResponseType, + FolderType, +} from "@chatbotx.io/database/partials" import { Button } from "@chatbotx.io/ui/components/ui/button" import type { Table } from "@tanstack/react-table" import { FolderUpIcon } from "lucide-react" @@ -15,12 +18,14 @@ type AutomatedResponseTableToolbarActionsProps = { table: Table workspaceId: string folderType: FolderType + type: AutomatedResponseType } export function AutomatedResponseTableToolbarActions({ table, workspaceId, folderType, + type, }: AutomatedResponseTableToolbarActionsProps) { const t = useTranslations() const router = useRouter() @@ -43,6 +48,7 @@ export function AutomatedResponseTableToolbarActions({ router.refresh() }} open={openDeleteDialog} + type={type} workspaceId={workspaceId} /> ()} id={row.original.id} + type={type} workspaceId={workspaceId} /> ), @@ -241,7 +242,7 @@ export function AutomatedResponsesTable({ enableHiding: false, }, ], - [workspaceId, basePath, t, allFlows, searchParams], + [workspaceId, basePath, t, allFlows, searchParams, type], ) const { table } = useDataTable({ @@ -270,6 +271,7 @@ export function AutomatedResponsesTable({ @@ -286,6 +288,7 @@ export function AutomatedResponsesTable({ }} open={rowAction?.variant === "delete"} showTrigger={false} + type={type} workspaceId={workspaceId} /> @@ -308,11 +311,17 @@ const AutomatedResponseStatusCell = (props: { id: string workspaceId: string checked: boolean + type: AutomatedResponseType }) => { const router = useRouter() const { execute, isPending } = useAction( - enableAutomatedResponseAction.bind(null, props.workspaceId, props.id), + enableAutomatedResponseAction.bind( + null, + props.workspaceId, + props.id, + props.type, + ), { onError: ({ error }) => { if (error.serverError) { diff --git a/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx b/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx index d7df3669ed..be072425e5 100644 --- a/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx +++ b/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx @@ -1,5 +1,6 @@ "use client" +import type { AutomatedResponseType } from "@chatbotx.io/database/partials" import type { AutomatedResponseModel } from "@chatbotx.io/database/types" import { Button } from "@chatbotx.io/ui/components/ui/button" import { @@ -28,6 +29,7 @@ type DeleteAutomatedResponsesDialogProps = ComponentPropsWithoutRef< showTrigger?: boolean onSuccess?: () => void onOpenChange: (val: boolean) => void + type: AutomatedResponseType } export function DeleteAutomatedResponsesDialog({ @@ -36,12 +38,13 @@ export function DeleteAutomatedResponsesDialog({ showTrigger = true, onSuccess, onOpenChange, + type, ...props }: DeleteAutomatedResponsesDialogProps) { const t = useTranslations() const { execute, isPending } = useAction( - deleteAutomatedResponseAction.bind(null, workspaceId), + deleteAutomatedResponseAction.bind(null, workspaceId, type), { onSuccess: () => { toast.success( diff --git a/apps/builder/src/features/automated-response/edit-automated-response-form.tsx b/apps/builder/src/features/automated-response/edit-automated-response-form.tsx index e51e61faba..a3ec89519b 100644 --- a/apps/builder/src/features/automated-response/edit-automated-response-form.tsx +++ b/apps/builder/src/features/automated-response/edit-automated-response-form.tsx @@ -1,5 +1,6 @@ "use client" +import type { AutomatedResponseType } from "@chatbotx.io/database/partials" import type { AutomatedResponseModel } from "@chatbotx.io/database/types" import { ComboboxField } from "@chatbotx.io/ui/components/form/combobox-field" import { InputField } from "@chatbotx.io/ui/components/form/input-field" @@ -26,12 +27,13 @@ import { responseModes, updateAutomatedResponseRequest } from "./schema/action" type EditAutomatedResponseFormProps = { workspaceId: string automatedResponse: AutomatedResponseModel + type: AutomatedResponseType } export default function EditAutomatedResponseForm( props: EditAutomatedResponseFormProps, ) { - const { workspaceId, automatedResponse } = props + const { workspaceId, automatedResponse, type } = props const t = useTranslations() const router = useRouter() @@ -43,7 +45,12 @@ export default function EditAutomatedResponseForm( handleSubmitWithAction, form: { control, setValue }, } = useHookFormAction( - updateAutomatedResponseAction.bind(null, workspaceId, automatedResponse.id), + updateAutomatedResponseAction.bind( + null, + workspaceId, + automatedResponse.id, + type, + ), zodResolver(updateAutomatedResponseRequest), { actionProps: { diff --git a/apps/builder/src/features/automated-response/schema/query.ts b/apps/builder/src/features/automated-response/schema/query.ts index 422465e64d..d573e59311 100644 --- a/apps/builder/src/features/automated-response/schema/query.ts +++ b/apps/builder/src/features/automated-response/schema/query.ts @@ -1,4 +1,7 @@ -import type { AutomatedResponseType } from "@chatbotx.io/database/partials" +import { + type AutomatedResponseType, + automatedResponseTypes, +} from "@chatbotx.io/database/partials" import type { AutomatedResponseModel } from "@chatbotx.io/database/types" import { getSortingStateParser } from "@chatbotx.io/ui/lib/parsers" import { zodBigintAsString } from "@chatbotx.io/utils" @@ -27,6 +30,7 @@ export type ListAutomatedResponsesRequest = Awaited< export const findAutomatedResponseRequest = z.object({ workspaceId: zodBigintAsString(), id: zodBigintAsString(), + type: automatedResponseTypes, }) export type FindAutomatedResponseRequest = z.infer< typeof findAutomatedResponseRequest diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index 87d7e9a6bb..1e03e9f9cd 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -1,3 +1,4 @@ +import { broadcastService } from "@chatbotx.io/business" import z from "zod" import { possibleErrorsOnFindingResource, @@ -5,11 +6,7 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { - listBroadcastAudience, - listBroadcasts, - publicGetBroadcast, -} from "../queries" +import { listBroadcastAudience, listBroadcasts } from "../queries" import { listBroadcastAudienceResponse, publicListBroadcastsResponse, @@ -51,7 +48,10 @@ export const broadcastsPublicRouter = { .errors(possibleErrorsOnFindingResource) .handler( async ({ context, input }) => - await publicGetBroadcast(context.workspace.id, input.idOrName), + await broadcastService.findByIdOrName({ + workspaceId: context.workspace.id, + idOrName: input.idOrName, + }), ), getAudience: workspaceTokenAuthAPI @@ -70,16 +70,13 @@ export const broadcastsPublicRouter = { ) .output(listBroadcastAudienceResponse) .errors(possibleErrorsOnFindingResource) - .handler(async ({ context, input }) => { - const broadcast = await publicGetBroadcast( - context.workspace.id, - input.idOrName, - ) - return await listBroadcastAudience({ - broadcastId: broadcast.id, - workspaceId: context.workspace.id, - page: input.page, - perPage: input.perPage, - }) - }), + .handler( + async ({ context, input }) => + await listBroadcastAudience({ + idOrName: input.idOrName, + workspaceId: context.workspace.id, + page: input.page, + perPage: input.perPage, + }), + ), } diff --git a/apps/builder/src/features/broadcasts/queries/index.ts b/apps/builder/src/features/broadcasts/queries/index.ts index 40b04d044c..36698bf9d2 100644 --- a/apps/builder/src/features/broadcasts/queries/index.ts +++ b/apps/builder/src/features/broadcasts/queries/index.ts @@ -1,6 +1,4 @@ -import { notFoundException } from "@chatbotx.io/business/errors" -import { broadcastRepository } from "@chatbotx.io/database/repositories" -import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" +import { broadcastService } from "@chatbotx.io/business" import type { PaginatedResponse } from "@/features/common/schema/pagination" import type { GetBroadcastsSchema } from "../schema/query" import type { BroadcastResourceWithRelations } from "../schema/resource" @@ -8,79 +6,14 @@ import type { BroadcastResourceWithRelations } from "../schema/resource" export async function listBroadcasts( input: GetBroadcastsSchema, ): Promise> { - const pagination = getPaginationWithDefaults(input) - - const [data, total] = await Promise.all([ - broadcastRepository.listWithRelations(input), - broadcastRepository.count(input), - ]) - - const pageCount = Math.ceil(total / pagination.limit) - - return { data, pageCount } + return await broadcastService.list(input) } export async function listBroadcastAudience(input: { - broadcastId: string + idOrName: string workspaceId: string page?: number | null perPage?: number | null }) { - const { limit, offset } = getPaginationWithDefaults(input) - - // Gate behind a non-deleted broadcast owned by this workspace — mirrors - // findByIdForResponse/listExistingIds so a soft-deleted (or foreign) - // broadcast never leaks its audience, even if a future caller skips the - // publicGetBroadcast lookup the current API handler happens to run first. - const broadcast = await broadcastRepository.findIdIfActive({ - id: input.broadcastId, - workspaceId: input.workspaceId, - }) - - if (!broadcast) { - throw notFoundException("Broadcast not found") - } - - const [rows, total] = await Promise.all([ - broadcastRepository.listAudience({ - broadcastId: input.broadcastId, - limit, - offset, - }), - broadcastRepository.countAudience(input.broadcastId), - ]) - - return { - data: rows.map((row) => ({ - contactId: row.contactId, - contact: { - id: row.contact.id, - firstName: row.contact.firstName, - lastName: row.contact.lastName, - fullName: row.contact.fullName, - email: row.contact.email, - phoneNumber: row.contact.phoneNumber, - avatar: row.contact.avatar, - gender: row.contact.gender, - }, - sent: row.sent, - })), - pageCount: Math.ceil(total / limit), - } -} - -export async function publicGetBroadcast( - workspaceId: string, - idOrName: string, -) { - const broadcast = await broadcastRepository.findByIdOrName({ - workspaceId, - idOrName, - }) - - if (!broadcast) { - throw notFoundException("Broadcast not found") - } - - return broadcast + return await broadcastService.listAudience(input) } diff --git a/apps/builder/src/features/flows/actions/publish-flow-action.ts b/apps/builder/src/features/flows/actions/publish-flow-action.ts index 1f89ff1987..ae6291b069 100644 --- a/apps/builder/src/features/flows/actions/publish-flow-action.ts +++ b/apps/builder/src/features/flows/actions/publish-flow-action.ts @@ -14,12 +14,10 @@ export const publishFlowAction = workspaceActionClient parsedInput, } = props - const validated = publishFlowSchema.parse(parsedInput) - await flowVersionService.publish({ workspaceId, flowId: id, - nodes: validated.nodes, - edges: validated.edges, + nodes: parsedInput.nodes, + edges: parsedInput.edges, }) }) diff --git a/apps/builder/src/features/sequences/actions/create-sequence.action.ts b/apps/builder/src/features/sequences/actions/create-sequence.action.ts index 59b7de54db..bf4e9a13d5 100644 --- a/apps/builder/src/features/sequences/actions/create-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/create-sequence.action.ts @@ -1,6 +1,7 @@ "use server" -import { sequenceService } from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" +import { sequenceService } from "@chatbotx.io/business/sequence" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" import { @@ -43,6 +44,13 @@ export const createSequenceAction = workspaceActionClient }) } + // A `ChatbotXException` (e.g. not-found) already carries a correct + // status/message — rethrow it unchanged so it doesn't get masked as + // a generic 500. Only genuinely unknown errors get wrapped. + if (error instanceof ChatbotXException) { + throw error + } + throw new Error("Failed to create sequence") } }, diff --git a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts index 1476d30332..9d2563e464 100644 --- a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts +++ b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts @@ -1,13 +1,12 @@ "use server" -import { sequenceService } from "@chatbotx.io/business" +import { sequenceService } from "@chatbotx.io/business/sequence" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" -import { recalculateAllContactsInSequence } from "@/features/contact-sequences/utils/calculate-next-run-at" import { workspaceActionClient } from "@/lib/safe-action" const deleteSequenceStepRequest = z.object({ @@ -32,7 +31,6 @@ export const deleteSequenceStepAction = workspaceActionClient await sequenceService.assertOwned({ workspaceId, sequenceId }) await sequenceService.deleteStep({ workspaceId, stepId }) - await recalculateAllContactsInSequence(sequenceId, workspaceId) return { success: true } }, diff --git a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts index 06147b277a..8ba489a125 100644 --- a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts @@ -1,6 +1,6 @@ "use server" -import { sequenceService } from "@chatbotx.io/business" +import { sequenceService } from "@chatbotx.io/business/sequence" import { zodBigintAsString } from "@chatbotx.io/utils" import { workspaceActionClient } from "@/lib/safe-action" diff --git a/apps/builder/src/features/sequences/actions/update-sequence.action.ts b/apps/builder/src/features/sequences/actions/update-sequence.action.ts index ca377f5212..a17012c932 100644 --- a/apps/builder/src/features/sequences/actions/update-sequence.action.ts +++ b/apps/builder/src/features/sequences/actions/update-sequence.action.ts @@ -1,7 +1,7 @@ "use server" -import { sequenceService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" +import { sequenceService } from "@chatbotx.io/business/sequence" import { zodBigintAsString } from "@chatbotx.io/utils" import { getTranslations } from "next-intl/server" import { returnValidationErrors } from "next-safe-action" diff --git a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts index 52a0b7f545..412f56232d 100644 --- a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts +++ b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts @@ -1,50 +1,16 @@ "use server" -import { sequenceService } from "@chatbotx.io/business" +import { sequenceService } from "@chatbotx.io/business/sequence" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, } from "@/features/common/schema" -import { - handleStepCreationImpact, - handleStepUpdateImpact, -} from "@/features/contact-sequences/utils/calculate-next-run-at" import { workspaceActionClient } from "@/lib/safe-action" import { type UpsertSequenceStepRequest, upsertSequenceStepRequest, } from "../schema/action" -/** - * Check if we need to recalculate contact schedules when UPDATING a step. - * - * RECALCULATE when these fields change: - * delayDays/delayMinutes/delayUnit: Changes step timing - * isActive: Step becomes available/unavailable → contacts skip or process - * order: Step position changes → affects timeline - * - * NO RECALCULATE when these fields change: - * flowId: Only changes message content, does not affect schedule - * sendTimeStart/sendTimeEnd: Only affects worker dispatch time - * sendDays: Only affects worker dispatch days - * anytime: Only affects worker dispatch logic - * specificDateTime: Handled within recalculation logic - */ -function shouldRecalculateOnUpdate( - parsedInput: UpsertSequenceStepRequest, - previousOrder: number, -): boolean { - const { delayDays, delayMinutes, delayUnit, isActive, order } = parsedInput - - return ( - delayDays !== undefined || - delayMinutes !== undefined || - delayUnit !== undefined || - isActive !== undefined || - order !== previousOrder - ) -} - export const upsertSequenceStepAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(upsertSequenceStepRequest) @@ -60,33 +26,11 @@ export const upsertSequenceStepAction = workspaceActionClient await sequenceService.assertOwned({ workspaceId, sequenceId }) - if (stepId) { - const { previousOrder, step } = await sequenceService.updateStep({ - workspaceId, - stepId, - data: parsedInput, - }) - - if (shouldRecalculateOnUpdate(parsedInput, previousOrder)) { - await handleStepUpdateImpact( - sequenceId, - workspaceId, - stepId, - parsedInput.order, - ) - } - - return { stepId: step.id } - } - - const step = await sequenceService.createStep({ + return await sequenceService.upsertStep({ workspaceId, sequenceId, + stepId, data: parsedInput, }) - - await handleStepCreationImpact(sequenceId, workspaceId, parsedInput.order) - - return { stepId: step.id } }, ) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 301a3dc569..06c25e2ca2 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -1,3 +1,4 @@ +import { sequenceService } from "@chatbotx.io/business/sequence" import z from "zod" import { possibleErrorsOnFindingResource, @@ -5,7 +6,7 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { getSequence, listSequences } from "../queries" +import { listSequences } from "../queries" import { listSequencesResponse } from "../schema/action" import { sequenceResource } from "../schema/resource" @@ -42,6 +43,9 @@ export const sequencesPublicRouter = { .errors(possibleErrorsOnFindingResource) .handler( async ({ context, input }) => - await getSequence(context.workspace.id, input.id), + await sequenceService.findWithSteps({ + workspaceId: context.workspace.id, + id: input.id, + }), ), } diff --git a/apps/builder/src/features/sequences/queries/index.ts b/apps/builder/src/features/sequences/queries/index.ts index 6ed02fcc8a..c9400f8684 100644 --- a/apps/builder/src/features/sequences/queries/index.ts +++ b/apps/builder/src/features/sequences/queries/index.ts @@ -1,6 +1,4 @@ -import { notFoundException } from "@chatbotx.io/business/errors" -import { sequenceRepository } from "@chatbotx.io/database/repositories" -import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" +import { sequenceService } from "@chatbotx.io/business/sequence" import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" import type { ListSequencesRequest, @@ -10,32 +8,14 @@ import type { export async function listSequences( input: ListSequencesRequest, ): Promise { - const pagination = getPaginationWithDefaults(input) - - const [data, total] = await Promise.all([ - sequenceRepository.listWithCounts(input), - sequenceRepository.count(input), - ]) - - const pageCount = Math.ceil(total / pagination.limit) - - return { data, pageCount } + return await sequenceService.list(input) } export async function getSequence(workspaceId: string, sequenceId: string) { await assertCurrentUserCanAccessChatbot(workspaceId) - const sequence = await sequenceRepository.findWithSteps({ - id: sequenceId, + return await sequenceService.findWithSteps({ workspaceId, + id: sequenceId, }) - - if (!sequence) { - throw notFoundException("Sequence not found") - } - - return { - ...sequence, - steps: sequence.sequenceSteps, - } } diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 29745b61c6..8433174402 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -1,7 +1,6 @@ import { triggerService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { folderTypes } from "@chatbotx.io/database/partials" -import { conditionRepository } from "@chatbotx.io/database/repositories" import type { TriggerModel } from "@chatbotx.io/database/types" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" @@ -120,8 +119,10 @@ export const triggersPublicRouter = { if (!updated) { throw notFoundException("Trigger not found") } - const updatedConditions = await conditionRepository.listByTriggerIds([id]) - return toResource({ ...updated, conditions: updatedConditions }) + return toResource({ + ...updated.trigger, + conditions: updated.conditions, + }) }), updateSettings: workspaceTokenAuthAPI @@ -142,18 +143,11 @@ export const triggersPublicRouter = { .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, ...patch } = input - await triggerService.updateSettings({ + const updated = await triggerService.updateSettings({ workspaceId: context.workspace.id, id, ...patch, }) - const updated = await triggerService.findWithConditions({ - id, - workspaceId: context.workspace.id, - }) - if (!updated) { - throw notFoundException("Trigger not found") - } return toResource(updated) }), diff --git a/packages/business/__tests__/ai-agent.service.test.ts b/packages/business/__tests__/ai-agent.service.test.ts index ce843fa3e3..bf1b1bad75 100644 --- a/packages/business/__tests__/ai-agent.service.test.ts +++ b/packages/business/__tests__/ai-agent.service.test.ts @@ -15,7 +15,8 @@ const { const mockUpdateWhere = vi.fn() const mockUpdateSet = vi.fn(() => ({ where: mockUpdateWhere })) const mockUpdate = vi.fn(() => ({ set: mockUpdateSet })) - const mockInsertValues = vi.fn() + const mockInsertReturning = vi.fn(async () => [{ id: "agent-1" }]) + const mockInsertValues = vi.fn(() => ({ returning: mockInsertReturning })) const mockInsert = vi.fn(() => ({ values: mockInsertValues })) const mockDeleteWhere = vi.fn() const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) diff --git a/packages/business/__tests__/automated-response.service.test.ts b/packages/business/__tests__/automated-response.service.test.ts index 9d58010610..008fe8f03e 100644 --- a/packages/business/__tests__/automated-response.service.test.ts +++ b/packages/business/__tests__/automated-response.service.test.ts @@ -76,6 +76,7 @@ vi.mock("@chatbotx.io/database/schema", () => ({ automatedResponseModel: { id: "automatedResponse.id", workspaceId: "automatedResponse.workspaceId", + type: "automatedResponse.type", }, })) @@ -117,7 +118,7 @@ describe("automatedResponseService audit side effects", () => { const tx = makeClient() await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { text: "Hello" }, tx as never, ) @@ -125,15 +126,24 @@ describe("automatedResponseService audit side effects", () => { expect(mocks.dispatchAuditRecord).not.toHaveBeenCalled() }) - test("does not audit update or setStatus when returning no row", async () => { + test("update throws not-found and never audits when returning no row", async () => { + mocks.updateReturning.mockResolvedValue([]) + + await expect( + automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, + { text: "Hello" }, + ), + ).rejects.toThrow("Automated response not found") + + expect(mocks.dispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("does not audit setStatus when returning no row", async () => { mocks.updateReturning.mockResolvedValue([]) - await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, - { text: "Hello" }, - ) await automatedResponseService.setStatus( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, false, ) @@ -143,17 +153,25 @@ describe("automatedResponseService audit side effects", () => { test("does not audit deleteMany when delete returning finds no rows", async () => { mocks.deleteReturning.mockResolvedValue([]) - await automatedResponseService.deleteMany("workspace-1", ["automation-1"]) + await automatedResponseService.deleteMany( + "workspace-1", + ["automation-1"], + "inbound", + ) expect(mocks.dispatchAuditRecord).not.toHaveBeenCalled() }) test("audits normal non-transaction update and delete", async () => { await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { text: "Hello" }, ) - await automatedResponseService.deleteMany("workspace-1", ["automation-1"]) + await automatedResponseService.deleteMany( + "workspace-1", + ["automation-1"], + "inbound", + ) expect(mocks.dispatchAuditRecord).toHaveBeenCalledWith({ action: "update", @@ -166,6 +184,89 @@ describe("automatedResponseService audit side effects", () => { }) }) +describe("automatedResponseService — type scoping (Contact vs Page keywords)", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.findFirst.mockResolvedValue({ + folderId: null, + keywords: ["hello"], + text: "Hi", + flowId: null, + status: true, + }) + mocks.updateReturning.mockResolvedValue([ + { id: "automation-1", keywords: ["hello"] }, + ]) + mocks.deleteReturning.mockResolvedValue([{ id: "automation-1" }]) + }) + + test("update includes an explicit type predicate in the WHERE clause", async () => { + await automatedResponseService.update( + { workspaceId: "workspace-1", id: "automation-1", type: "outbound" }, + { text: "Hello" }, + ) + + const whereArgs = mocks.updateWhere.mock.calls.at(0)?.[0] as { + and: unknown[] + } + const typePredicate = whereArgs.and.find( + (predicate) => + (predicate as { eq: unknown[] }).eq?.[0] === + "automatedResponse.workspaceId", + ) + expect(whereArgs.and).toContainEqual({ + eq: ["automatedResponse.workspaceId", "workspace-1"], + }) + expect(typePredicate).toBeDefined() + }) + + test("setStatus scopes the read and the write by type", async () => { + await automatedResponseService.setStatus( + { workspaceId: "workspace-1", id: "automation-1", type: "outbound" }, + true, + ) + + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ type: "outbound" }), + }), + ) + }) + + test("deleteMany scopes the DELETE by type so it never removes the other type's row", async () => { + await automatedResponseService.deleteMany( + "workspace-1", + ["automation-1"], + "outbound", + ) + + const whereArgs = mocks.deleteWhere.mock.calls.at(0)?.[0] as { + and: unknown[] + } + expect(whereArgs.and).toContainEqual({ + eq: ["automatedResponse.workspaceId", "workspace-1"], + }) + }) + + test("findOrFail scopes the lookup by type", async () => { + mocks.findFirst.mockResolvedValueOnce(undefined) + + await expect( + automatedResponseService.findOrFail({ + workspaceId: "workspace-1", + id: "automation-1", + type: "outbound", + }), + ).rejects.toThrow("Automated response not found") + + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ type: "outbound" }), + }), + ) + }) +}) + describe("automatedResponseService.update — keywords and flowId/text invariants", () => { beforeEach(() => { vi.clearAllMocks() @@ -185,7 +286,7 @@ describe("automatedResponseService.update — keywords and flowId/text invariant // unconditionally set keywords to `[]`, silently wiping the automation. test("omitting keywords does not wipe the existing keywords column", async () => { await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { text: "hi" }, ) @@ -196,7 +297,7 @@ describe("automatedResponseService.update — keywords and flowId/text invariant test("explicitly supplied keywords are still applied", async () => { await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { keywords: [{ value: "new" }] }, ) @@ -207,7 +308,7 @@ describe("automatedResponseService.update — keywords and flowId/text invariant test("nulls flowId when text is set", async () => { await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { text: "hi", flowId: "flow-1" }, ) @@ -221,7 +322,7 @@ describe("automatedResponseService.update — keywords and flowId/text invariant mocks.flowExists.mockResolvedValue(true) await automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { flowId: "flow-1" }, ) @@ -240,7 +341,7 @@ describe("automatedResponseService.update — keywords and flowId/text invariant await expect( automatedResponseService.update( - { workspaceId: "workspace-1", id: "automation-1" }, + { workspaceId: "workspace-1", id: "automation-1", type: "inbound" }, { flowId: "foreign-flow" }, ), ).rejects.toMatchObject({ field: "flowId", message: "Flow not found" }) diff --git a/packages/business/__tests__/broadcast-service-audience.test.ts b/packages/business/__tests__/broadcast-service-audience.test.ts new file mode 100644 index 0000000000..6516646dd4 --- /dev/null +++ b/packages/business/__tests__/broadcast-service-audience.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { mockFindByIdOrName, mockListAudience, mockCountAudience } = vi.hoisted( + () => ({ + mockFindByIdOrName: vi.fn(), + mockListAudience: vi.fn().mockResolvedValue([]), + mockCountAudience: vi.fn().mockResolvedValue(0), + }), +) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + findByIdOrName: mockFindByIdOrName, + listAudience: mockListAudience, + countAudience: mockCountAudience, + }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + getPaginationWithDefaults: (input: { page?: number; perPage?: number }) => ({ + limit: input.perPage ?? 10, + offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), + }), + likeContains: (value: string) => value, +})) + +const { broadcastService } = await import("../src/broadcast/service") + +describe("broadcastService.listAudience deletedAt gate", () => { + beforeEach(() => { + vi.clearAllMocks() + mockListAudience.mockResolvedValue([]) + mockCountAudience.mockResolvedValue(0) + }) + + test("looks up the broadcast scoped to workspaceId + idOrName + deletedAt IS NULL before listing recipients", async () => { + mockFindByIdOrName.mockResolvedValue({ id: "b-1" }) + + await broadcastService.listAudience({ + idOrName: "b-1", + workspaceId: "ws-1", + page: 1, + perPage: 10, + }) + + expect(mockFindByIdOrName).toHaveBeenCalledWith({ + idOrName: "b-1", + workspaceId: "ws-1", + }) + expect(mockListAudience).toHaveBeenCalled() + }) + + test("throws not-found for a soft-deleted broadcast and never queries recipients", async () => { + mockFindByIdOrName.mockResolvedValue(undefined) + + await expect( + broadcastService.listAudience({ + idOrName: "b-deleted", + workspaceId: "ws-1", + page: 1, + perPage: 10, + }), + ).rejects.toThrow("Broadcast not found") + + expect(mockListAudience).not.toHaveBeenCalled() + expect(mockCountAudience).not.toHaveBeenCalled() + }) + + test("throws not-found when the broadcast exists but belongs to a different workspace", async () => { + mockFindByIdOrName.mockResolvedValue(undefined) + + await expect( + broadcastService.listAudience({ + idOrName: "b-1", + workspaceId: "ws-foreign", + page: 1, + perPage: 10, + }), + ).rejects.toThrow("Broadcast not found") + + expect(mockFindByIdOrName).toHaveBeenCalledWith({ + idOrName: "b-1", + workspaceId: "ws-foreign", + }) + }) +}) diff --git a/packages/business/__tests__/broadcast-service-create.test.ts b/packages/business/__tests__/broadcast-service-create.test.ts index a3e0fa671a..114911842a 100644 --- a/packages/business/__tests__/broadcast-service-create.test.ts +++ b/packages/business/__tests__/broadcast-service-create.test.ts @@ -75,6 +75,18 @@ vi.mock("@chatbotx.io/database/queries", () => ({ vi.mock("@chatbotx.io/database/utils", () => ({ chunkById: vi.fn(), likeContains: vi.fn(), + getPaginationWithDefaults: vi.fn(() => ({ limit: 10, offset: 0 })), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + listWithRelations: vi.fn(), + count: vi.fn(), + findIdIfActive: vi.fn(), + listAudience: vi.fn(), + countAudience: vi.fn(), + findByIdOrName: vi.fn(), + }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/__tests__/broadcast-service-resend.test.ts b/packages/business/__tests__/broadcast-service-resend.test.ts index 475bb4cbdf..abdb808a9c 100644 --- a/packages/business/__tests__/broadcast-service-resend.test.ts +++ b/packages/business/__tests__/broadcast-service-resend.test.ts @@ -71,6 +71,18 @@ vi.mock("@chatbotx.io/database/queries", () => ({ vi.mock("@chatbotx.io/database/utils", () => ({ chunkById: vi.fn(), likeContains: vi.fn(), + getPaginationWithDefaults: vi.fn(() => ({ limit: 10, offset: 0 })), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + listWithRelations: vi.fn(), + count: vi.fn(), + findIdIfActive: vi.fn(), + listAudience: vi.fn(), + countAudience: vi.fn(), + findByIdOrName: vi.fn(), + }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/__tests__/broadcast-service-transitions.test.ts b/packages/business/__tests__/broadcast-service-transitions.test.ts index d0a3aa8ce1..e91b8f6aba 100644 --- a/packages/business/__tests__/broadcast-service-transitions.test.ts +++ b/packages/business/__tests__/broadcast-service-transitions.test.ts @@ -90,6 +90,18 @@ vi.mock("@chatbotx.io/database/queries", () => ({ vi.mock("@chatbotx.io/database/utils", () => ({ chunkById: vi.fn(), likeContains: (value: string) => `%${value}%`, + getPaginationWithDefaults: vi.fn(() => ({ limit: 10, offset: 0 })), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + listWithRelations: vi.fn(), + count: vi.fn(), + findIdIfActive: vi.fn(), + listAudience: vi.fn(), + countAudience: vi.fn(), + findByIdOrName: vi.fn(), + }, })) vi.mock("../src/inbox/service", () => ({ inboxService: {} })) diff --git a/packages/business/__tests__/broadcast-service-update.test.ts b/packages/business/__tests__/broadcast-service-update.test.ts index 856dab4883..8f997f1cc5 100644 --- a/packages/business/__tests__/broadcast-service-update.test.ts +++ b/packages/business/__tests__/broadcast-service-update.test.ts @@ -57,6 +57,18 @@ vi.mock("@chatbotx.io/database/queries", () => ({ vi.mock("@chatbotx.io/database/utils", () => ({ chunkById: vi.fn(), likeContains: vi.fn(), + getPaginationWithDefaults: vi.fn(() => ({ limit: 10, offset: 0 })), +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + broadcastRepository: { + listWithRelations: vi.fn(), + count: vi.fn(), + findIdIfActive: vi.fn(), + listAudience: vi.fn(), + countAudience: vi.fn(), + findByIdOrName: vi.fn(), + }, })) vi.mock("@chatbotx.io/utils", () => ({ diff --git a/packages/business/__tests__/sequence-service.test.ts b/packages/business/__tests__/sequence-service.test.ts index 4bf7865b31..25696a4c09 100644 --- a/packages/business/__tests__/sequence-service.test.ts +++ b/packages/business/__tests__/sequence-service.test.ts @@ -15,6 +15,7 @@ const { mockStepUpdateSet, mockStepUpdateReturning, mockStepInsert, + mockStepInsertReturning, mockStepDelete, sequenceModelStub, sequenceStepModelStub, @@ -57,6 +58,7 @@ const { mockStepUpdateSet, mockStepUpdateReturning, mockStepInsert, + mockStepInsertReturning, mockStepDelete, sequenceModelStub: { id: "sequenceModel.id", @@ -92,10 +94,37 @@ vi.mock("@chatbotx.io/utils", () => ({ createId: mockCreateId, })) +vi.mock("@chatbotx.io/database/repositories", () => ({ + sequenceRepository: { + listWithCounts: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + findWithSteps: vi.fn(), + }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + getPaginationWithDefaults: (input: { page?: number; perPage?: number }) => ({ + limit: input.perPage ?? 10, + offset: ((input.page ?? 1) - 1) * (input.perPage ?? 10), + }), +})) + vi.mock("../src/audit/dispatcher", () => ({ dispatchAuditRecord: mockDispatchAuditRecord, })) +const mockRecalculateAllContactsInSequence = vi + .fn() + .mockResolvedValue(undefined) +const mockHandleStepCreationImpact = vi.fn().mockResolvedValue(undefined) +const mockHandleStepUpdateImpact = vi.fn().mockResolvedValue(undefined) + +vi.mock("../src/sequence/contact-schedule", () => ({ + recalculateAllContactsInSequence: mockRecalculateAllContactsInSequence, + handleStepCreationImpact: mockHandleStepCreationImpact, + handleStepUpdateImpact: mockHandleStepUpdateImpact, +})) + const { sequenceService } = await import("../src/sequence/service") const WS = "ws-1" @@ -332,14 +361,82 @@ describe("sequenceService.updateStep / deleteStep cross-workspace rejection", () expect(mockStepDelete).not.toHaveBeenCalled() }) - test("deleteStep deletes when the step belongs to the workspace", async () => { + test("deleteStep deletes when the step belongs to the workspace and recalculates contact schedules", async () => { mockStepFindFirst.mockResolvedValue({ id: "step-1", + sequenceId: "seq-1", sequence: { workspaceId: WS }, }) await sequenceService.deleteStep({ workspaceId: WS, stepId: "step-1" }) expect(mockStepDelete).toHaveBeenCalled() + expect(mockRecalculateAllContactsInSequence).toHaveBeenCalledWith( + "seq-1", + WS, + ) + }) +}) + +describe("sequenceService.upsertStep", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test("create path: creates the step and calls handleStepCreationImpact", async () => { + mockStepInsertReturning.mockResolvedValue([{ id: "new-step-id" }]) + + const result = await sequenceService.upsertStep({ + workspaceId: WS, + sequenceId: "seq-1", + data: { order: 0 }, + }) + + expect(mockHandleStepCreationImpact).toHaveBeenCalledWith("seq-1", WS, 0) + expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() + expect(result).toEqual({ stepId: "new-step-id" }) + }) + + test("update path: calls handleStepUpdateImpact when delayDays changes", async () => { + mockStepFindFirst.mockResolvedValue({ + id: "step-1", + order: 1, + sequence: { workspaceId: WS }, + }) + mockStepUpdateReturning.mockResolvedValue([{ id: "step-1" }]) + + const result = await sequenceService.upsertStep({ + workspaceId: WS, + sequenceId: "seq-1", + stepId: "step-1", + data: { order: 1, delayDays: 3 }, + }) + + expect(mockHandleStepUpdateImpact).toHaveBeenCalledWith( + "seq-1", + WS, + "step-1", + 1, + ) + expect(mockHandleStepCreationImpact).not.toHaveBeenCalled() + expect(result).toEqual({ stepId: "step-1" }) + }) + + test("update path: does not recalculate when only flowId changes and order is unchanged", async () => { + mockStepFindFirst.mockResolvedValue({ + id: "step-1", + order: 1, + sequence: { workspaceId: WS }, + }) + mockStepUpdateReturning.mockResolvedValue([{ id: "step-1" }]) + + await sequenceService.upsertStep({ + workspaceId: WS, + sequenceId: "seq-1", + stepId: "step-1", + data: { order: 1, flowId: "flow-abc" }, + }) + + expect(mockHandleStepUpdateImpact).not.toHaveBeenCalled() }) }) diff --git a/packages/business/__tests__/trigger-service-update-settings.test.ts b/packages/business/__tests__/trigger-service-update-settings.test.ts index 519d309f2e..f074dc3ddf 100644 --- a/packages/business/__tests__/trigger-service-update-settings.test.ts +++ b/packages/business/__tests__/trigger-service-update-settings.test.ts @@ -38,8 +38,13 @@ vi.mock("@chatbotx.io/database/schema", () => ({ triggerModel: { id: "triggerModel.id" }, })) +const mockFindWithConditions = vi.fn() + vi.mock("@chatbotx.io/database/repositories", () => ({ - triggerRepository: { listPaginatedWithConditions: vi.fn() }, + triggerRepository: { + listPaginatedWithConditions: vi.fn(), + findWithConditions: mockFindWithConditions, + }, })) vi.mock("@chatbotx.io/events", () => ({ @@ -69,6 +74,13 @@ describe("triggerService.updateSettings", () => { vi.clearAllMocks() }) + const setUpMocks = () => { + mockFindWithConditions.mockResolvedValue({ + id: TRIGGER_ID, + conditions: [], + }) + } + test("throws notFoundException when the trigger does not exist", async () => { mockTriggerFindFirst.mockResolvedValue(undefined) @@ -84,6 +96,7 @@ describe("triggerService.updateSettings", () => { }) test("no-ops without writing or auditing when nothing changed", async () => { + setUpMocks() mockTriggerFindFirst.mockResolvedValue({ id: TRIGGER_ID, name: "Same name", @@ -101,6 +114,7 @@ describe("triggerService.updateSettings", () => { }) test("audits as 'enabled' when only active flips to true", async () => { + setUpMocks() mockTriggerFindFirst.mockResolvedValue({ id: TRIGGER_ID, name: "Trigger", @@ -120,6 +134,7 @@ describe("triggerService.updateSettings", () => { }) test("audits as 'disabled' when only active flips to false", async () => { + setUpMocks() mockTriggerFindFirst.mockResolvedValue({ id: TRIGGER_ID, name: "Trigger", @@ -139,6 +154,7 @@ describe("triggerService.updateSettings", () => { }) test("audits a generic 'updated' detail when a non-active field changes", async () => { + setUpMocks() mockTriggerFindFirst.mockResolvedValue({ id: TRIGGER_ID, name: "Old name", @@ -158,6 +174,7 @@ describe("triggerService.updateSettings", () => { }) test("does not audit when the update affects zero rows", async () => { + setUpMocks() mockTriggerFindFirst.mockResolvedValue({ id: TRIGGER_ID, name: "Old name", diff --git a/packages/business/__tests__/trigger-service-update-with-conditions.test.ts b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts index 8fa692ed97..c2dc692182 100644 --- a/packages/business/__tests__/trigger-service-update-with-conditions.test.ts +++ b/packages/business/__tests__/trigger-service-update-with-conditions.test.ts @@ -139,7 +139,7 @@ describe("triggerService.updateWithConditions", () => { conditions: [], }) - expect(result).toEqual({ id: TRIGGER_ID }) + expect(result).toEqual({ trigger: { id: TRIGGER_ID }, conditions: [] }) // Cache updates when the trigger exists, regardless of hasRealChange. expect(mockUpdateTriggerCache).toHaveBeenCalledWith(WS) expect(mockDispatchAuditRecord).not.toHaveBeenCalled() diff --git a/packages/business/package.json b/packages/business/package.json index 8a6067ddc0..b16a2a22fa 100644 --- a/packages/business/package.json +++ b/packages/business/package.json @@ -30,6 +30,7 @@ "./smart-delay": "./src/smart-delay/index.ts", "./errors": "./src/errors.ts", "./referral": "./src/referral/index.ts", + "./sequence": "./src/sequence/index.ts", "./system-field": "./src/system-field/index.ts", "./utils": "./src/utils.ts", "./workspace-api-token/credentials": "./src/workspace-api-token/credentials.ts", @@ -52,6 +53,7 @@ "@chatbotx.io/logger": "workspace:*", "@chatbotx.io/partysocket-config": "workspace:*", "@chatbotx.io/redis": "workspace:*", + "@chatbotx.io/scheduler": "workspace:*", "@chatbotx.io/sdk": "workspace:*", "@chatbotx.io/sequence-scheduler": "workspace:*", "@chatbotx.io/utils": "workspace:*", diff --git a/packages/business/src/ai-agent/service.ts b/packages/business/src/ai-agent/service.ts index ac1c918f10..01da7b3c3d 100644 --- a/packages/business/src/ai-agent/service.ts +++ b/packages/business/src/ai-agent/service.ts @@ -243,6 +243,15 @@ class AiAgentService extends BaseService { data: CreateAIAgentRequest, tx?: DatabaseClient, ): Promise { + const created = await this.createAndReturn(workspaceId, data, tx) + return created.id + } + + async createAndReturn( + workspaceId: string, + data: CreateAIAgentRequest, + tx?: DatabaseClient, + ): Promise { const id = createId() const execute = async (client: DatabaseClient) => { @@ -253,17 +262,21 @@ class AiAgentService extends BaseService { .where(eq(aiAgentModel.workspaceId, workspaceId)) } const { webSearchAuthorizedDomains, ...rest } = data - await client.insert(aiAgentModel).values({ - ...rest, - webSearchAuthorizedDomains: normalizeWebSearchDomains( - webSearchAuthorizedDomains, - ), - workspaceId, - id, - }) + const [inserted] = await client + .insert(aiAgentModel) + .values({ + ...rest, + webSearchAuthorizedDomains: normalizeWebSearchDomains( + webSearchAuthorizedDomains, + ), + workspaceId, + id, + }) + .returning() + return inserted } - await (tx ? execute(tx) : db.transaction(execute)) + const created = await (tx ? execute(tx) : db.transaction(execute)) await this.invalidateCacheTags(this.getWorkspaceCacheTag(workspaceId)) @@ -271,13 +284,13 @@ class AiAgentService extends BaseService { await this.audit("create", `created a new AI Agent (#${id})`) } - return id + return created } async updateAIAgent( ctx: { workspaceId: string; id: string }, data: UpdateAIAgentRequest, - ): Promise { + ): Promise { const aiAgent = await this.findBy({ where: { id: ctx.id, workspaceId: ctx.workspaceId }, }) @@ -288,7 +301,7 @@ class AiAgentService extends BaseService { const hasChanges = Object.values(data).some((value) => value !== undefined) if (!hasChanges) { - return + return aiAgent } await db.transaction(async (tx) => { @@ -325,7 +338,7 @@ class AiAgentService extends BaseService { : `unset default an AI Agent (#${aiAgent.id})`, ) } - return + return await this.requireUpdatedAgent(ctx) } const changedGroups: AIAgentChangeGroup[] = [] @@ -353,12 +366,27 @@ class AiAgentService extends BaseService { "update", `${buildChangeGroupMessage(changedGroups)} (#${aiAgent.id})`, ) - return + return await this.requireUpdatedAgent(ctx) } if (hasOtherFieldChanges(aiAgent, data)) { await this.audit("update", `updated an AI Agent (#${aiAgent.id})`) } + + return await this.requireUpdatedAgent(ctx) + } + + private async requireUpdatedAgent(ctx: { + workspaceId: string + id: string + }): Promise { + const updated = await this.findBy({ + where: { id: ctx.id, workspaceId: ctx.workspaceId }, + }) + if (!updated) { + throw notFoundException("AI agent not found") + } + return updated } /** diff --git a/packages/business/src/automated-response/service.ts b/packages/business/src/automated-response/service.ts index 520840d090..5bd8da706a 100644 --- a/packages/business/src/automated-response/service.ts +++ b/packages/business/src/automated-response/service.ts @@ -38,7 +38,7 @@ export type UpdateAutomatedResponseRequest = { export type FindAutomatedResponseRequest = { workspaceId: string id: string - type?: AutomatedResponseType + type: AutomatedResponseType } export type ListAutomatedResponsesRequest = { @@ -61,7 +61,7 @@ class AutomatedResponseService extends BaseService { where: { workspaceId: input.workspaceId, id: input.id, - ...(input.type ? { type: input.type } : {}), + type: input.type, }, }) } @@ -214,7 +214,7 @@ class AutomatedResponseService extends BaseService { } async update( - ctx: { id: string; workspaceId: string; type?: AutomatedResponseType }, + ctx: { id: string; workspaceId: string; type: AutomatedResponseType }, data: UpdateAutomatedResponseRequest, tx?: DatabaseClient, ): Promise { @@ -226,7 +226,7 @@ class AutomatedResponseService extends BaseService { where: { id: ctx.id, workspaceId: ctx.workspaceId, - ...(ctx.type ? { type: ctx.type } : {}), + type: ctx.type, }, columns: { folderId: true, keywords: true, text: true, flowId: true }, }) @@ -270,14 +270,14 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.id, ctx.id), eq(automatedResponseModel.workspaceId, ctx.workspaceId), - ...(ctx.type ? [eq(automatedResponseModel.type, ctx.type)] : []), + eq(automatedResponseModel.type, ctx.type), ), ) .returning() await this.invalidateCache(ctx.workspaceId) if (!updated) { - return updated as unknown as AutomatedResponseModel + throw notFoundException("Automated response not found") } const keywordsChanged = @@ -305,7 +305,7 @@ class AutomatedResponseService extends BaseService { } async setStatus( - ctx: { id: string; workspaceId: string; type?: AutomatedResponseType }, + ctx: { id: string; workspaceId: string; type: AutomatedResponseType }, status: boolean, tx?: DatabaseClient, ): Promise { @@ -315,7 +315,7 @@ class AutomatedResponseService extends BaseService { where: { id: ctx.id, workspaceId: ctx.workspaceId, - ...(ctx.type ? { type: ctx.type } : {}), + type: ctx.type, }, columns: { status: true }, }) @@ -327,7 +327,7 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.id, ctx.id), eq(automatedResponseModel.workspaceId, ctx.workspaceId), - ...(ctx.type ? [eq(automatedResponseModel.type, ctx.type)] : []), + eq(automatedResponseModel.type, ctx.type), ), ) .returning() @@ -350,8 +350,8 @@ class AutomatedResponseService extends BaseService { async deleteMany( workspaceId: string, ids: string[], + type: AutomatedResponseType, tx?: DatabaseClient, - type?: AutomatedResponseType, ): Promise { await assertDeletable({ workspaceId, @@ -367,7 +367,7 @@ class AutomatedResponseService extends BaseService { and( eq(automatedResponseModel.workspaceId, workspaceId), inArray(automatedResponseModel.id, ids), - ...(type ? [eq(automatedResponseModel.type, type)] : []), + eq(automatedResponseModel.type, type), ), ) .returning({ id: automatedResponseModel.id }) diff --git a/packages/business/src/broadcast/service.ts b/packages/business/src/broadcast/service.ts index 7b739db468..48e1e72f1c 100644 --- a/packages/business/src/broadcast/service.ts +++ b/packages/business/src/broadcast/service.ts @@ -31,6 +31,10 @@ import { contactInboxInteractedWithin24hSQL, pruneEmailPhoneFilterConditions, } from "@chatbotx.io/database/queries" +import { + type BroadcastListInput, + broadcastRepository, +} from "@chatbotx.io/database/repositories" import { broadcastModel, contactInboxModel, @@ -48,12 +52,20 @@ import type { IntegrationMessengerModel, IntegrationWhatsappModel, } from "@chatbotx.io/database/types" -import { chunkById, likeContains } from "@chatbotx.io/database/utils" +import { + chunkById, + getPaginationWithDefaults, + likeContains, +} from "@chatbotx.io/database/utils" import type { WaTemplateParams } from "@chatbotx.io/flow-config" import { createId } from "@chatbotx.io/utils" import { startOfMinute } from "date-fns" import { BaseService } from "../base.service" -import { ChatbotXException, validationException } from "../errors" +import { + ChatbotXException, + notFoundException, + validationException, +} from "../errors" import { inboxService } from "../inbox/service" import type { BroadcastAudienceInput, @@ -127,6 +139,82 @@ export type BroadcastCalendarRow = BroadcastModel & { } class BroadcastService extends BaseService { + /** + * Paginated broadcast list with relations — shared by the public API + * (`GET /v1/broadcasts`) and the builder's broadcasts page. + */ + async list(input: BroadcastListInput) { + const pagination = getPaginationWithDefaults(input) + + const [data, total] = await Promise.all([ + broadcastRepository.listWithRelations(input), + broadcastRepository.count(input), + ]) + + return { data, pageCount: Math.ceil(total / pagination.limit) } + } + + /** + * Gates the audience read behind a non-deleted broadcast owned by this + * workspace (resolved by id-or-name), then returns the paginated audience + * rows. A single existence gate — callers must not re-resolve the + * broadcast separately before calling this. + */ + async listAudience(input: { + idOrName: string + workspaceId: string + page?: number | null + perPage?: number | null + }) { + const { limit, offset } = getPaginationWithDefaults(input) + + const broadcast = await broadcastRepository.findByIdOrName({ + idOrName: input.idOrName, + workspaceId: input.workspaceId, + }) + + if (!broadcast) { + throw notFoundException("Broadcast not found") + } + + const [rows, total] = await Promise.all([ + broadcastRepository.listAudience({ + broadcastId: broadcast.id, + limit, + offset, + }), + broadcastRepository.countAudience(broadcast.id), + ]) + + return { + data: rows.map((row) => ({ + contactId: row.contactId, + contact: { + id: row.contact.id, + firstName: row.contact.firstName, + lastName: row.contact.lastName, + fullName: row.contact.fullName, + email: row.contact.email, + phoneNumber: row.contact.phoneNumber, + avatar: row.contact.avatar, + gender: row.contact.gender, + }, + sent: row.sent, + })), + pageCount: Math.ceil(total / limit), + } + } + + async findByIdOrName(input: { workspaceId: string; idOrName: string }) { + const broadcast = await broadcastRepository.findByIdOrName(input) + + if (!broadcast) { + throw notFoundException("Broadcast not found") + } + + return broadcast + } + async findByIdForResponse(input: { workspaceId: string broadcastId: string diff --git a/packages/business/src/index.ts b/packages/business/src/index.ts index e81e49b9a3..1513e3dba0 100644 --- a/packages/business/src/index.ts +++ b/packages/business/src/index.ts @@ -87,7 +87,12 @@ export { parseLiveCount } from "./quota-shared/live-counter-store" export * from "./referral" export * from "./reflink" export * from "./saved-reply" -export * from "./sequence" +// Not barrel-exported: `sequenceService.upsertStep`/`deleteStep` reach +// sequence-scheduler's `createDispatch`, which hashes with Node's `crypto`. +// This barrel is traced into the builder's Edge Runtime bundle, where a Node +// built-in is a hard compile error — see edge-safe-import-graph.test.ts and +// the same pattern for `contact-sequence`. Import from +// `@chatbotx.io/business/sequence` instead. export * from "./smart-delay" export * from "./spreadsheet" export * from "./tag" diff --git a/apps/builder/src/features/contact-sequences/utils/calculate-next-run-at.ts b/packages/business/src/sequence/contact-schedule.ts similarity index 92% rename from apps/builder/src/features/contact-sequences/utils/calculate-next-run-at.ts rename to packages/business/src/sequence/contact-schedule.ts index 91cc383eaf..eae97a0bad 100644 --- a/apps/builder/src/features/contact-sequences/utils/calculate-next-run-at.ts +++ b/packages/business/src/sequence/contact-schedule.ts @@ -61,7 +61,6 @@ async function createAndScheduleDispatch( client, }) - // biome-ignore lint/correctness/useHookAtTopLevel: useExisting is not a React hook const redisClient = await sequenceConnections.useExisting() const scheduler = new SchedulerClient(redisClient) await scheduler.addToSchedule( @@ -72,62 +71,6 @@ async function createAndScheduleDispatch( } } -export async function calculateNextRunAtBulk( - sequenceIds: string[], - enrolledAt: Date = new Date(), - tx?: DatabaseClient, -): Promise> { - const client = tx ?? db - - const firstSteps = await client.query.sequenceStepModel.findMany({ - where: { - sequenceId: { in: sequenceIds }, - order: 0, - isActive: true, - }, - columns: { - id: true, - sequenceId: true, - delayDays: true, - delayMinutes: true, - delayUnit: true, - specificDateTime: true, - }, - }) - - const stepMap = new Map(firstSteps.map((step) => [step.sequenceId, step])) - - const resultMap = new Map< - string, - { nextRunAt: Date; nextStepId: string | null } - >() - for (const sequenceId of sequenceIds) { - const step = stepMap.get(sequenceId) - if (!step) { - resultMap.set(sequenceId, { nextRunAt: enrolledAt, nextStepId: null }) - continue - } - - if (step.delayUnit === "specificTime" && step.specificDateTime) { - resultMap.set(sequenceId, { - nextRunAt: step.specificDateTime, - nextStepId: step.id, - }) - continue - } - - const delayMs = - step.delayDays * 24 * 60 * 60 * 1000 + step.delayMinutes * 60 * 1000 - resultMap.set(sequenceId, { - nextRunAt: - delayMs > 0 ? new Date(enrolledAt.getTime() + delayMs) : enrolledAt, - nextStepId: step.id, - }) - } - - return resultMap -} - function calculateDelayInMs(delayDays: number, delayMinutes: number): number { return delayDays * 24 * 60 * 60 * 1000 + delayMinutes * 60 * 1000 } diff --git a/packages/business/src/sequence/service.ts b/packages/business/src/sequence/service.ts index 77aec57a69..e5c253e5fa 100644 --- a/packages/business/src/sequence/service.ts +++ b/packages/business/src/sequence/service.ts @@ -5,21 +5,76 @@ import { findOrFail, isUniqueViolationError, } from "@chatbotx.io/database/client" +import { + type SequenceListInput, + sequenceRepository, +} from "@chatbotx.io/database/repositories" import { sequenceModel, sequenceStepModel } from "@chatbotx.io/database/schema" import type { SequenceModel, SequenceStepModel, } from "@chatbotx.io/database/types" +import { getPaginationWithDefaults } from "@chatbotx.io/database/utils" import { createId } from "@chatbotx.io/utils" import { BaseService } from "../base.service" import { notFoundException, validationException } from "../errors" +import { + handleStepCreationImpact, + handleStepUpdateImpact, + recalculateAllContactsInSequence, +} from "./contact-schedule" import { buildCreateData, buildUpdateData, type SequenceStepPayloadInput, } from "./step-payload" +/** + * Check if we need to recalculate contact schedules when UPDATING a step. + * + * RECALCULATE when these fields change: + * delayDays/delayMinutes/delayUnit: Changes step timing + * isActive: Step becomes available/unavailable → contacts skip or process + * order: Step position changes → affects timeline + * + * NO RECALCULATE when these fields change: + * flowId: Only changes message content, does not affect schedule + * sendTimeStart/sendTimeEnd: Only affects worker dispatch time + * sendDays: Only affects worker dispatch days + * anytime: Only affects worker dispatch logic + * specificDateTime: Handled within recalculation logic + */ +function shouldRecalculateOnUpdate( + parsedInput: SequenceStepPayloadInput, + previousOrder: number, +): boolean { + const { delayDays, delayMinutes, delayUnit, isActive, order } = parsedInput + + return ( + delayDays !== undefined || + delayMinutes !== undefined || + delayUnit !== undefined || + isActive !== undefined || + order !== previousOrder + ) +} + class SequenceService extends BaseService { + /** + * SQL-paginated sequence list with step counts — shared by the public API + * (`GET /v1/sequences`) and the builder's sequences page. + */ + async list(input: SequenceListInput) { + const pagination = getPaginationWithDefaults(input) + + const [data, total] = await Promise.all([ + sequenceRepository.listWithCounts(input), + sequenceRepository.count(input), + ]) + + return { data, pageCount: Math.ceil(total / pagination.limit) } + } + async create(input: { workspaceId: string name: string @@ -142,6 +197,22 @@ class SequenceService extends BaseService { }) } + async findWithSteps(input: { workspaceId: string; id: string }) { + const sequence = await sequenceRepository.findWithSteps({ + id: input.id, + workspaceId: input.workspaceId, + }) + + if (!sequence) { + throw notFoundException("Sequence not found") + } + + return { + ...sequence, + steps: sequence.sequenceSteps, + } + } + async createStep(input: { workspaceId: string sequenceId: string @@ -213,6 +284,52 @@ class SequenceService extends BaseService { await db .delete(sequenceStepModel) .where(eq(sequenceStepModel.id, input.stepId)) + + await recalculateAllContactsInSequence(step.sequenceId, input.workspaceId) + } + + /** + * Creates or updates a sequence step and recalculates contact schedules + * when the change actually affects timing (see `shouldRecalculateOnUpdate`). + */ + async upsertStep(input: { + workspaceId: string + sequenceId: string + stepId?: string + data: SequenceStepPayloadInput + }): Promise<{ stepId: string }> { + if (input.stepId) { + const { previousOrder, step } = await this.updateStep({ + workspaceId: input.workspaceId, + stepId: input.stepId, + data: input.data, + }) + + if (shouldRecalculateOnUpdate(input.data, previousOrder)) { + await handleStepUpdateImpact( + input.sequenceId, + input.workspaceId, + input.stepId, + input.data.order, + ) + } + + return { stepId: step.id } + } + + const step = await this.createStep({ + workspaceId: input.workspaceId, + sequenceId: input.sequenceId, + data: input.data, + }) + + await handleStepCreationImpact( + input.sequenceId, + input.workspaceId, + input.data.order, + ) + + return { stepId: step.id } } } diff --git a/packages/business/src/trigger/service.ts b/packages/business/src/trigger/service.ts index 7c8abb2d99..a28307f9a4 100644 --- a/packages/business/src/trigger/service.ts +++ b/packages/business/src/trigger/service.ts @@ -155,7 +155,13 @@ class TriggerService extends BaseService { id: string actions: TriggerModel["actions"] conditions: ConditionInput[] - }): Promise { + }): Promise< + | { + trigger: TriggerModel + conditions: (typeof conditionModel.$inferSelect)[] + } + | undefined + > { const { workspaceId, id, actions, conditions } = input const result = await db.transaction(async (tx) => { @@ -174,7 +180,11 @@ class TriggerService extends BaseService { ]) if (!existingTrigger) { - return { trigger: undefined, hasRealChange: false } + return { + trigger: undefined, + conditions: undefined, + hasRealChange: false, + } } const existingIds = new Set(existingConditions.map((c) => c.id)) @@ -253,14 +263,22 @@ class TriggerService extends BaseService { ) } - const trigger = await tx.query.triggerModel.findFirst({ - where: { - id, - }, - }) + const [trigger, updatedConditions] = await Promise.all([ + tx.query.triggerModel.findFirst({ + where: { + id, + }, + }), + tx.query.conditionModel.findMany({ + where: { + triggerId: id, + }, + }), + ]) return { trigger, + conditions: updatedConditions, hasRealChange: actionsChanged || conditionsToDelete.length > 0 || @@ -278,6 +296,8 @@ class TriggerService extends BaseService { } return result.trigger + ? { trigger: result.trigger, conditions: result.conditions ?? [] } + : undefined } /** @@ -291,7 +311,9 @@ class TriggerService extends BaseService { id: string name?: string active?: boolean - }): Promise { + }): Promise< + TriggerModel & { conditions: (typeof conditionModel.$inferSelect)[] } + > { const { workspaceId, id, ...patch } = input const trigger = await db.query.triggerModel.findFirst({ @@ -309,29 +331,32 @@ class TriggerService extends BaseService { ([key, value]) => trigger[key as keyof typeof patch] !== value, ) - if (changedEntries.length === 0) { - return - } - - const updated = await db - .update(triggerModel) - .set(patch) - .where(eq(triggerModel.id, trigger.id)) - .returning({ id: triggerModel.id }) - - if (updated.length === 0) { - return + if (changedEntries.length > 0) { + const updated = await db + .update(triggerModel) + .set(patch) + .where(eq(triggerModel.id, trigger.id)) + .returning({ id: triggerModel.id }) + + if (updated.length > 0) { + const changedKeys = changedEntries.map(([key]) => key) + let detail = `updated a trigger (#${trigger.id})` + if (changedKeys.length === 1 && changedKeys[0] === "active") { + detail = patch.active + ? `enabled a trigger (#${trigger.id})` + : `disabled a trigger (#${trigger.id})` + } + + await this.audit("update", detail) + } } - const changedKeys = changedEntries.map(([key]) => key) - let detail = `updated a trigger (#${trigger.id})` - if (changedKeys.length === 1 && changedKeys[0] === "active") { - detail = patch.active - ? `enabled a trigger (#${trigger.id})` - : `disabled a trigger (#${trigger.id})` - } + const withConditions = await triggerRepository.findWithConditions({ + id, + workspaceId, + }) - await this.audit("update", detail) + return withConditions ?? { ...trigger, conditions: [] } } } diff --git a/packages/business/src/webhook/service.ts b/packages/business/src/webhook/service.ts index 7c2ebd1c6f..30a97b02b9 100644 --- a/packages/business/src/webhook/service.ts +++ b/packages/business/src/webhook/service.ts @@ -335,7 +335,8 @@ class WebhookService extends BaseService { async updateSettings(input: { workspaceId: string id: string - [key: string]: unknown + name?: string + active?: boolean }): Promise { const { workspaceId, id, ...patch } = input diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43f6ec797..3b70a20fac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2192,6 +2192,9 @@ importers: '@chatbotx.io/redis': specifier: workspace:* version: link:../redis + '@chatbotx.io/scheduler': + specifier: workspace:* + version: link:../scheduler '@chatbotx.io/sdk': specifier: workspace:* version: link:../sdk From 0897a9f4b0cc4789da990650220ee540a83f9ad4 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Wed, 9 Sep 2026 15:46:48 +0700 Subject: [PATCH 8/8] fix(automation): move template picker query into business and scope reflink writes Move the template picker's selectable-resource dispatch out of the builder query layer into `templateService.listSelectableResources`, so the query file is a thin adapter and the repository is no longer reached from `apps/`. Scope `reflinkService` update/deleteMany/listOptions by `type = "refLink"` to match what `create` stamps, so an entry-point-link row sharing the table can never be updated or deleted through the reflink surface. Stop swallowing every error on the flow pages: only a service-thrown `notFoundException` becomes `notFound()`, and a DB failure propagates as a real 500 instead of a misleading 404. Drop the now-unused `broadcastRepository.findIdIfActive` and `findContactFilter` along with their tests and service mocks. --- .../flows/[id]/analytics/page.tsx | 8 +- .../space/[workspaceId]/flows/[id]/page.tsx | 8 +- .../queries/list-selectable-resources.ts | 186 +--------------- .../src/lib/errors/validation-exception.ts | 12 ++ .../broadcast-service-create.test.ts | 1 - .../broadcast-service-resend.test.ts | 1 - .../broadcast-service-transitions.test.ts | 1 - .../broadcast-service-update.test.ts | 1 - packages/business/src/reflink/service.ts | 2 + packages/business/src/template/index.ts | 2 + packages/business/src/template/service.ts | 203 ++++++++++++++++++ .../__tests__/broadcast-repository.test.ts | 37 ---- .../src/repositories/broadcast/repository.ts | 41 ---- 13 files changed, 241 insertions(+), 262 deletions(-) diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx index 4b967b1278..f9b1aff086 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/analytics/page.tsx @@ -10,6 +10,7 @@ import { FlowStoreProvider } from "@/features/flows/provider/flow-store-context" import { FlowTemplateStoreProvider } from "@/features/flows/react-flow/stores/flow-template-store-provider" import { withWorkspaceIdAndIdSchema } from "@/features/workspaces/schema/resource" import { requireWorkspacePermission } from "@/lib/auth/require-workspace-permission" +import { isNotFoundException } from "@/lib/errors/validation-exception" type FlowAnalyticsPageProps = { params: Promise<{ workspaceId: string; id: string }> @@ -31,8 +32,11 @@ export default async function FlowAnalyticsPage({ id: data.id, workspaceId: data.workspaceId, }) - } catch { - return notFound() + } catch (error) { + if (isNotFoundException(error)) { + return notFound() + } + throw error } const draftFlowVersion = flow.flowVersions?.find((v) => v.isDraft) diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx index 25392c733b..0ec95668af 100644 --- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx @@ -5,6 +5,7 @@ import { isSameContent } from "@/features/flows/flow-version-content" import { listIntegrationOpenaiCompatible } from "@/features/integration-openai-compatible/queries" import { withWorkspaceIdAndIdSchema } from "@/features/workspaces/schema/resource" import { requireWorkspacePermission } from "@/lib/auth/require-workspace-permission" +import { isNotFoundException } from "@/lib/errors/validation-exception" type FlowPageProps = { params: Promise<{ workspaceId: string; id: string }> @@ -24,8 +25,11 @@ export default async function FlowPage({ params }: FlowPageProps) { id: data.id, workspaceId: data.workspaceId, }) - } catch { - return notFound() + } catch (error) { + if (isNotFoundException(error)) { + return notFound() + } + throw error } const draftFlowVersion = flow.flowVersions?.find((v) => v.isDraft) diff --git a/apps/builder/src/features/templates/queries/list-selectable-resources.ts b/apps/builder/src/features/templates/queries/list-selectable-resources.ts index a7d12ba431..251c55b185 100644 --- a/apps/builder/src/features/templates/queries/list-selectable-resources.ts +++ b/apps/builder/src/features/templates/queries/list-selectable-resources.ts @@ -1,185 +1,19 @@ +import { + type ListSelectableResourcesResult, + templateService, +} from "@chatbotx.io/business" import type { TemplateCategory } from "@chatbotx.io/database/partials" -import { templateSelectableResourceRepository } from "@chatbotx.io/database/repositories" -const PAGE_SIZE = 100 -const ALL_IDS_CAP = 1000 +export type { + ListSelectableResourcesResult, + SelectableResourceItem, +} from "@chatbotx.io/business" -export type SelectableResourceItem = { - id: string - name: string - folderName?: string -} - -export type ListSelectableResourcesResult = { - items: SelectableResourceItem[] - nextCursor: string | null - total: number - allIds?: string[] -} - -/** - * One unified query for the template picker's category tabs, so the picker - * depends on a single seam rather than each category's own incompatible - * list-query signature. Search is server-side `ilike` (never client-side - * `.toLowerCase()`, which is locale-broken for Vietnamese names). Returns - * `allIds` alongside page 1 whenever `total <= ALL_IDS_CAP`, so a - * `mode:"all"` -> uncheck-one-row downgrade on the client can be exact - * instead of guessing at what "all" means. - */ export const listSelectableResources = async (input: { workspaceId: string category: TemplateCategory keyword?: string | null cursor?: string | null limit?: number | null -}): Promise => { - const limit = input.limit ?? PAGE_SIZE - const offset = input.cursor ? Number.parseInt(input.cursor, 10) || 0 : 0 - - const categoryInput = { - workspaceId: input.workspaceId, - keyword: input.keyword, - offset, - limit, - } - - switch (input.category) { - case "flows": - return projectRows( - await templateSelectableResourceRepository.listFlows(categoryInput), - offset, - limit, - ) - case "tags": - return projectRows( - await templateSelectableResourceRepository.listTags(categoryInput), - offset, - limit, - ) - case "customFields": - return projectRows( - await templateSelectableResourceRepository.listCustomFields( - categoryInput, - ), - offset, - limit, - ) - case "products": - return projectRows( - await templateSelectableResourceRepository.listProducts(categoryInput), - offset, - limit, - ) - case "aiFunctions": - return projectRows( - await templateSelectableResourceRepository.listAIFunctions( - categoryInput, - ), - offset, - limit, - ) - case "aiAgents": - return projectRows( - await templateSelectableResourceRepository.listAIAgents(categoryInput), - offset, - limit, - ) - case "calendars": - return projectRows( - await templateSelectableResourceRepository.listCalendars(categoryInput), - offset, - limit, - ) - case "webchats": - return projectRows( - await templateSelectableResourceRepository.listWebchats(categoryInput), - offset, - limit, - ) - case "triggers": - return projectRows( - await templateSelectableResourceRepository.listTriggers(categoryInput), - offset, - limit, - ) - case "fbCommentAutomations": - return projectRows( - await templateSelectableResourceRepository.listFbCommentAutomations( - categoryInput, - ), - offset, - limit, - ) - case "keywords": - return projectRows( - await templateSelectableResourceRepository.listKeywords(categoryInput), - offset, - limit, - ) - case "entryPointLinks": - return projectRows( - await templateSelectableResourceRepository.listEntryPointLinks( - categoryInput, - ), - offset, - limit, - ) - case "settings": - return listSettings(input.workspaceId, input.keyword, offset, limit) - default: - return { items: [], nextCursor: null, total: 0 } - } -} - -const projectRows = ( - result: { rows: SelectableResourceItem[]; total: number; allIds?: string[] }, - offset: number, - limit: number, -): ListSelectableResourcesResult => ({ - items: result.rows, - nextCursor: - offset + result.rows.length < result.total ? String(offset + limit) : null, - total: result.total, - allIds: result.allIds, -}) - -/** - * `settings` bundles two tables (`SavedReply`, `BotField`) under one - * category, mirroring `settingsAdapter`'s two-kind entries. Search and - * pagination run in memory over the combined, name-sorted list — both - * tables are small, workspace-admin-configured settings, never large enough - * to warrant a real cross-table paginated query. - */ -const listSettings = async ( - workspaceId: string, - keyword: string | null | undefined, - offset: number, - limit: number, -): Promise => { - const { savedReplies, botFields } = - await templateSelectableResourceRepository.listSettings(workspaceId) - - const all = [ - ...savedReplies.map((row) => ({ id: row.id, name: row.shortcut })), - ...botFields.map((row) => ({ id: row.id, name: row.name })), - ].sort((a, b) => a.name.localeCompare(b.name)) - - const filtered = keyword - ? all.filter((row) => - row.name.toLowerCase().includes(keyword.toLowerCase()), - ) - : all - - const total = filtered.length - const page = filtered.slice(offset, offset + limit) - - return { - items: page, - nextCursor: offset + page.length < total ? String(offset + limit) : null, - total, - allIds: - offset === 0 && total <= ALL_IDS_CAP - ? filtered.map((row) => row.id) - : undefined, - } -} +}): Promise => + await templateService.listSelectableResources(input) diff --git a/apps/builder/src/lib/errors/validation-exception.ts b/apps/builder/src/lib/errors/validation-exception.ts index 22c438e407..dee5e5261e 100644 --- a/apps/builder/src/lib/errors/validation-exception.ts +++ b/apps/builder/src/lib/errors/validation-exception.ts @@ -13,3 +13,15 @@ export function isValidationException( ): error is ChatbotXException & { code: "validation" } { return error instanceof ChatbotXException && error.code === "validation" } + +/** + * Narrows a caught error to a service-thrown `notFoundException` + * (`packages/business/src/errors.ts`). Use this to turn a missing row into a + * `notFound()` response while letting a DB connection failure or other + * Drizzle error propagate as a real 500 instead of being swallowed. + */ +export function isNotFoundException( + error: unknown, +): error is ChatbotXException & { code: "notFound" } { + return error instanceof ChatbotXException && error.code === "notFound" +} diff --git a/packages/business/__tests__/broadcast-service-create.test.ts b/packages/business/__tests__/broadcast-service-create.test.ts index 114911842a..c7d27f5e03 100644 --- a/packages/business/__tests__/broadcast-service-create.test.ts +++ b/packages/business/__tests__/broadcast-service-create.test.ts @@ -82,7 +82,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ broadcastRepository: { listWithRelations: vi.fn(), count: vi.fn(), - findIdIfActive: vi.fn(), listAudience: vi.fn(), countAudience: vi.fn(), findByIdOrName: vi.fn(), diff --git a/packages/business/__tests__/broadcast-service-resend.test.ts b/packages/business/__tests__/broadcast-service-resend.test.ts index abdb808a9c..a05b339de7 100644 --- a/packages/business/__tests__/broadcast-service-resend.test.ts +++ b/packages/business/__tests__/broadcast-service-resend.test.ts @@ -78,7 +78,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ broadcastRepository: { listWithRelations: vi.fn(), count: vi.fn(), - findIdIfActive: vi.fn(), listAudience: vi.fn(), countAudience: vi.fn(), findByIdOrName: vi.fn(), diff --git a/packages/business/__tests__/broadcast-service-transitions.test.ts b/packages/business/__tests__/broadcast-service-transitions.test.ts index e91b8f6aba..9ce3cc69b6 100644 --- a/packages/business/__tests__/broadcast-service-transitions.test.ts +++ b/packages/business/__tests__/broadcast-service-transitions.test.ts @@ -97,7 +97,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ broadcastRepository: { listWithRelations: vi.fn(), count: vi.fn(), - findIdIfActive: vi.fn(), listAudience: vi.fn(), countAudience: vi.fn(), findByIdOrName: vi.fn(), diff --git a/packages/business/__tests__/broadcast-service-update.test.ts b/packages/business/__tests__/broadcast-service-update.test.ts index 8f997f1cc5..4114b66c6c 100644 --- a/packages/business/__tests__/broadcast-service-update.test.ts +++ b/packages/business/__tests__/broadcast-service-update.test.ts @@ -64,7 +64,6 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ broadcastRepository: { listWithRelations: vi.fn(), count: vi.fn(), - findIdIfActive: vi.fn(), listAudience: vi.fn(), countAudience: vi.fn(), findByIdOrName: vi.fn(), diff --git a/packages/business/src/reflink/service.ts b/packages/business/src/reflink/service.ts index 9315c8ae41..66a090e5c4 100644 --- a/packages/business/src/reflink/service.ts +++ b/packages/business/src/reflink/service.ts @@ -106,6 +106,7 @@ class ReflinkService extends BaseService { and( eq(reflinkModel.id, reflink.id), eq(reflinkModel.workspaceId, ctx.workspaceId), + eq(reflinkModel.type, "refLink"), ), ) .returning() @@ -152,6 +153,7 @@ class ReflinkService extends BaseService { .where( and( eq(reflinkModel.workspaceId, input.workspaceId), + eq(reflinkModel.type, "refLink"), inArray(reflinkModel.id, input.ids), ), ) diff --git a/packages/business/src/template/index.ts b/packages/business/src/template/index.ts index 9a6b335a65..755e1bd178 100644 --- a/packages/business/src/template/index.ts +++ b/packages/business/src/template/index.ts @@ -4,7 +4,9 @@ export { templateAllowDeleteViolationException, } from "./installed-resource.service" export { + type ListSelectableResourcesResult, type PublicTemplateProjection, + type SelectableResourceItem, templateCrossTenantInstallException, templateService, templateShareDisabledException, diff --git a/packages/business/src/template/service.ts b/packages/business/src/template/service.ts index 3d91d8fc50..17fafbfc4f 100644 --- a/packages/business/src/template/service.ts +++ b/packages/business/src/template/service.ts @@ -1,8 +1,10 @@ import { db, eq } from "@chatbotx.io/database/client" import type { + TemplateCategory, TemplatePermissions, TemplateSelection, } from "@chatbotx.io/database/partials" +import { templateSelectableResourceRepository } from "@chatbotx.io/database/repositories" import { templateInstallationModel, templateModel, @@ -18,6 +20,22 @@ import { workspaceService } from "../workspace" import { generateShareToken } from "./share-token" import { buildTemplateSnapshot } from "./snapshot.service" +const SELECTABLE_RESOURCE_PAGE_SIZE = 100 +const SELECTABLE_RESOURCE_ALL_IDS_CAP = 1000 + +export type SelectableResourceItem = { + id: string + name: string + folderName?: string +} + +export type ListSelectableResourcesResult = { + items: SelectableResourceItem[] + nextCursor: string | null + total: number + allIds?: string[] +} + export const templateShareDisabledException = () => new ChatbotXException( "This share link is no longer available", @@ -410,6 +428,191 @@ class TemplateService { .set({ deletedAt: new Date() }) .where(eq(templateModel.id, input.templateId)) } + + /** + * One unified query for the template picker's category tabs, so the + * picker depends on a single seam rather than each category's own + * incompatible list-query signature. Search is server-side `ilike` + * (never client-side `.toLowerCase()`, which is locale-broken for + * Vietnamese names). Returns `allIds` alongside page 1 whenever `total <= + * SELECTABLE_RESOURCE_ALL_IDS_CAP`, so a `mode:"all"` -> uncheck-one-row + * downgrade on the client can be exact instead of guessing at what "all" + * means. + */ + async listSelectableResources(input: { + workspaceId: string + category: TemplateCategory + keyword?: string | null + cursor?: string | null + limit?: number | null + }): Promise { + const limit = input.limit ?? SELECTABLE_RESOURCE_PAGE_SIZE + const offset = input.cursor ? Number.parseInt(input.cursor, 10) || 0 : 0 + + const categoryInput = { + workspaceId: input.workspaceId, + keyword: input.keyword, + offset, + limit, + } + + switch (input.category) { + case "flows": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listFlows(categoryInput), + offset, + limit, + ) + case "tags": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listTags(categoryInput), + offset, + limit, + ) + case "customFields": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listCustomFields( + categoryInput, + ), + offset, + limit, + ) + case "products": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listProducts( + categoryInput, + ), + offset, + limit, + ) + case "aiFunctions": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listAIFunctions( + categoryInput, + ), + offset, + limit, + ) + case "aiAgents": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listAIAgents( + categoryInput, + ), + offset, + limit, + ) + case "calendars": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listCalendars( + categoryInput, + ), + offset, + limit, + ) + case "webchats": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listWebchats( + categoryInput, + ), + offset, + limit, + ) + case "triggers": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listTriggers( + categoryInput, + ), + offset, + limit, + ) + case "fbCommentAutomations": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listFbCommentAutomations( + categoryInput, + ), + offset, + limit, + ) + case "keywords": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listKeywords( + categoryInput, + ), + offset, + limit, + ) + case "entryPointLinks": + return projectSelectableResourceRows( + await templateSelectableResourceRepository.listEntryPointLinks( + categoryInput, + ), + offset, + limit, + ) + case "settings": + return listSelectableSettings( + input.workspaceId, + input.keyword, + offset, + limit, + ) + default: + return { items: [], nextCursor: null, total: 0 } + } + } +} + +const projectSelectableResourceRows = ( + result: { rows: SelectableResourceItem[]; total: number; allIds?: string[] }, + offset: number, + limit: number, +): ListSelectableResourcesResult => ({ + items: result.rows, + nextCursor: + offset + result.rows.length < result.total ? String(offset + limit) : null, + total: result.total, + allIds: result.allIds, +}) + +/** + * `settings` bundles two tables (`SavedReply`, `BotField`) under one + * category, mirroring `settingsAdapter`'s two-kind entries. Search and + * pagination run in memory over the combined, name-sorted list — both + * tables are small, workspace-admin-configured settings, never large enough + * to warrant a real cross-table paginated query. + */ +const listSelectableSettings = async ( + workspaceId: string, + keyword: string | null | undefined, + offset: number, + limit: number, +): Promise => { + const { savedReplies, botFields } = + await templateSelectableResourceRepository.listSettings(workspaceId) + + const all = [ + ...savedReplies.map((row) => ({ id: row.id, name: row.shortcut })), + ...botFields.map((row) => ({ id: row.id, name: row.name })), + ].sort((a, b) => a.name.localeCompare(b.name)) + + const filtered = keyword + ? all.filter((row) => + row.name.toLowerCase().includes(keyword.toLowerCase()), + ) + : all + + const total = filtered.length + const page = filtered.slice(offset, offset + limit) + + return { + items: page, + nextCursor: offset + page.length < total ? String(offset + limit) : null, + total, + allIds: + offset === 0 && total <= SELECTABLE_RESOURCE_ALL_IDS_CAP + ? filtered.map((row) => row.id) + : undefined, + } } export const templateService = new TemplateService() diff --git a/packages/database/__tests__/broadcast-repository.test.ts b/packages/database/__tests__/broadcast-repository.test.ts index f84e2522ca..d5fc9091c7 100644 --- a/packages/database/__tests__/broadcast-repository.test.ts +++ b/packages/database/__tests__/broadcast-repository.test.ts @@ -94,43 +94,6 @@ describe("broadcastRepository.listWithRelations", () => { }) }) -describe("broadcastRepository.findIdIfActive", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - test("scopes to a non-deleted broadcast owned by the workspace", async () => { - mocks.findFirst.mockResolvedValue({ id: "broadcast-1" }) - - const result = await broadcastRepository.findIdIfActive({ - id: "broadcast-1", - workspaceId: "ws-1", - }) - - expect(result).toEqual({ id: "broadcast-1" }) - expect(mocks.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - id: "broadcast-1", - workspaceId: "ws-1", - deletedAt: { isNull: true }, - }, - }), - ) - }) - - test("returns undefined when no row matches", async () => { - mocks.findFirst.mockResolvedValue(undefined) - - const result = await broadcastRepository.findIdIfActive({ - id: "missing", - workspaceId: "ws-1", - }) - - expect(result).toBeUndefined() - }) -}) - describe("broadcastRepository.listAudience / countAudience", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/database/src/repositories/broadcast/repository.ts b/packages/database/src/repositories/broadcast/repository.ts index 036eee86e4..210487e061 100644 --- a/packages/database/src/repositories/broadcast/repository.ts +++ b/packages/database/src/repositories/broadcast/repository.ts @@ -74,27 +74,6 @@ export const broadcastRepository = { ) }, - /** - * Ownership gate before listing a broadcast's audience — scoped to a - * non-deleted broadcast owned by this workspace so a soft-deleted (or - * foreign) broadcast never leaks its audience, even if a future caller - * skips the `publicGetBroadcast` lookup the current API handler happens to - * run first. - */ - async findIdIfActive( - input: { id: string; workspaceId: string }, - tx: DatabaseClient = db, - ): Promise<{ id: string } | undefined> { - return await tx.query.broadcastModel.findFirst({ - where: { - id: input.id, - workspaceId: input.workspaceId, - deletedAt: { isNull: true }, - }, - columns: { id: true }, - }) - }, - async listAudience( input: { broadcastId: string; limit: number; offset: number }, tx: DatabaseClient = db, @@ -131,24 +110,4 @@ export const broadcastRepository = { return await tx.query.broadcastModel.findFirst({ where }) }, - - /** - * Reads only the stored `contactFilter` of a broadcast — used by the - * resend action to re-derive the pruned filter with the CURRENT caller's - * email/phone visibility, rather than trusting whatever was pruned into - * the original broadcast. - */ - async findContactFilter( - input: { id: string; workspaceId: string }, - tx: DatabaseClient = db, - ): Promise<{ contactFilter: unknown } | undefined> { - return await tx.query.broadcastModel.findFirst({ - where: { - id: input.id, - workspaceId: input.workspaceId, - deletedAt: { isNull: true }, - }, - columns: { contactFilter: true }, - }) - }, }