Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions packages/business/__tests__/comment-automation-write-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
assertDeletable: vi.fn(),
flowExists: vi.fn(),
}))

vi.mock("@chatbotx.io/database/client", () => ({
Expand Down Expand Up @@ -66,6 +67,10 @@ vi.mock("../src/template/installed-resource.service", () => ({
assertDeletable: mocks.assertDeletable,
}))

vi.mock("../src/flow/service", () => ({
flowService: { exists: mocks.flowExists },
}))

const { commentAutomationService } = await import(
"../src/comment-automation/service"
)
Expand All @@ -74,6 +79,7 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.findFirst.mockResolvedValue(undefined)
mocks.assertDeletable.mockResolvedValue(undefined)
mocks.flowExists.mockResolvedValue(true)
})

describe("commentAutomationService — type-scoped writes", () => {
Expand Down Expand Up @@ -223,4 +229,160 @@ describe("commentAutomationService — type-scoped writes", () => {
}),
)
})

test("createMessenger rejects a privateReply flow that does not exist in this workspace", async () => {
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.createMessenger({
workspaceId: "1",
data: {
name: "hello",
privateReply: { type: "flow", value: "flow-from-another-space" },
},
}),
).rejects.toMatchObject({ field: "privateReply" })

expect(mocks.flowExists).toHaveBeenCalledWith(
"1",
"flow-from-another-space",
undefined,
)
expect(mocks.insert).not.toHaveBeenCalled()
})

test("createMessenger rejects a publicReply flow that does not exist in this workspace", async () => {
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.createMessenger({
workspaceId: "1",
data: {
name: "hello",
publicReply: { type: "flow", value: "flow-from-another-space" },
},
}),
).rejects.toMatchObject({ field: "publicReply" })

expect(mocks.insert).not.toHaveBeenCalled()
})

test("createMessenger inserts when both reply flows exist in this workspace", async () => {
mocks.flowExists.mockResolvedValue(true)
const returning = vi.fn().mockResolvedValue([{ id: "id-1" }])
const values = vi.fn(() => ({ returning }))
mocks.insert.mockReturnValue({ values })

await commentAutomationService.createMessenger({
workspaceId: "1",
data: {
name: "hello",
privateReply: { type: "flow", value: "flow-1" },
},
})

expect(mocks.flowExists).toHaveBeenCalledWith("1", "flow-1", undefined)
expect(values).toHaveBeenCalled()
})

test("createMessenger never calls flowService when neither reply is a flow", async () => {
const returning = vi.fn().mockResolvedValue([{ id: "id-1" }])
const values = vi.fn(() => ({ returning }))
mocks.insert.mockReturnValue({ values })

await commentAutomationService.createMessenger({
workspaceId: "1",
data: { name: "hello", privateReply: { type: "text", value: "hi" } },
})

expect(mocks.flowExists).not.toHaveBeenCalled()
})

test("updateInstagram rejects a privateReply flow from another workspace", async () => {
mocks.findFirst.mockResolvedValue({ id: "9", type: "instagram" })
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.updateInstagram(
{ workspaceId: "1", id: "9" },
{ privateReply: { type: "flow", value: "flow-from-another-space" } },
),
).rejects.toMatchObject({ field: "privateReply" })

expect(mocks.update).not.toHaveBeenCalled()
})

test("createThreadsAutomation rejects a publicReply flow from another workspace", async () => {
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.createThreadsAutomation({
workspaceId: "1",
data: {
name: "hello",
post: { type: "all", value: [] },
publicReply: { type: "flow", value: "flow-from-another-space" },
includeKeywords: { type: "all", value: [] },
excludeKeywords: [],
options: {
replyToNewContactsOnly: false,
replyOncePerUserPerPost: false,
replyToUsersWhoCommentedOnOtherPosts: true,
ignoreCommentReplies: true,
},
replyAfter: { type: "immediately", value: 0 },
},
}),
).rejects.toMatchObject({ field: "publicReply" })

expect(mocks.flowExists).toHaveBeenCalledWith(
"1",
"flow-from-another-space",
expect.anything(),
)
expect(mocks.insert).not.toHaveBeenCalled()
})

test("updateThreadsAutomation rejects a publicReply flow from another workspace", async () => {
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.updateThreadsAutomation({
workspaceId: "1",
id: "9",
data: {
publicReply: { type: "flow", value: "flow-from-another-space" },
},
}),
).rejects.toMatchObject({ field: "publicReply" })

expect(mocks.update).not.toHaveBeenCalled()
})

test("createTiktokAutomation rejects a publicReply flow from another workspace", async () => {
mocks.flowExists.mockResolvedValue(false)

await expect(
commentAutomationService.createTiktokAutomation({
workspaceId: "1",
data: {
name: "hello",
post: { type: "all", value: [] },
publicReply: { type: "flow", value: "flow-from-another-space" },
includeKeywords: { type: "all", value: [] },
excludeKeywords: [],
options: {
replyToNewContactsOnly: false,
replyOncePerUserPerPost: false,
likeUserComment: false,
replyToUsersWhoCommentedOnOtherPosts: true,
ignoreCommentReplies: true,
},
replyAfter: { type: "immediately", value: 0 },
},
}),
).rejects.toMatchObject({ field: "publicReply" })

expect(mocks.insert).not.toHaveBeenCalled()
})
})
7 changes: 7 additions & 0 deletions packages/business/__tests__/comment-automation.list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ vi.mock("@chatbotx.io/database/partials", () => ({
rootFolderId: "0",
}))

// service.ts now calls flowService.exists() to validate a flow reply; this
// suite isn't about that check, so stub it to always resolve true and avoid
// pulling in flowService's own (unrelated) dependency chain.
vi.mock("../src/flow/service", () => ({
flowService: { exists: vi.fn().mockResolvedValue(true) },
}))

vi.mock("@chatbotx.io/database/schema", () => ({
contactInboxModel: {},
commentAutomationModel: { name: "commentAutomation.name" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ vi.mock("@chatbotx.io/database/partials", () => ({
},
}))

// service.ts now calls flowService.exists() to validate a flow reply; this
// suite isn't about that check, so stub it to always resolve true and avoid
// pulling in flowService's own (unrelated) dependency chain.
vi.mock("../src/flow/service", () => ({
flowService: { exists: vi.fn().mockResolvedValue(true) },
}))

vi.mock("@chatbotx.io/database/schema", () => ({
contactInboxModel: { contactId: "ContactInbox.contactId" },
commentAutomationModel: {
Expand Down
99 changes: 98 additions & 1 deletion packages/business/src/comment-automation/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
import { createId } from "@chatbotx.io/utils"
import { formatInTimeZone } from "date-fns-tz"
import { BaseService } from "../base.service"
import { notFoundException } from "../errors"
import { notFoundException, validationException } from "../errors"
import { flowService } from "../flow/service"
import { resolveFolderIdFilter } from "../lib/folder-filter"
import { assertDeletable } from "../template/installed-resource.service"

Expand Down Expand Up @@ -512,10 +513,52 @@ class CommentAutomationService extends BaseService {
return { ...data, publicReply: normalizeReplyTexts(data.publicReply) }
}

/**
* `AutomatedResponse` (Keywords) validates a `flowId` against
* `flowService.exists` before saving it (`automated-response/service.ts`);
* this table never did, for either reply field or any of its 8 write
* methods. The worker itself scopes the lookup to the triggering
* conversation's own workspace (`detectFlowVersion`,
* `apps/worker/src/lib/db.ts`) for BOTH `privateReply`
* (`private-reply.ts`) and `publicReply` (`public-reply.ts`) — a foreign
* flowId can never run cross-tenant, it just fails at delivery time with a
* bare `FlowVersion not found`, on an automation that otherwise looks
* active. Catching it at write time turns a silent dead automation into an
* immediate, actionable error. Structurally typed so it accepts every
* reply shape this table's channels use — Messenger/Instagram's
* `CommentReply`, and Threads/TikTok's channel-specific reply type (both
* fix `privateReply` to `{type: "none"}` and never let the caller set it,
* so only their `publicReply` needs covering).
*/
private async assertFlowReplyExists(
workspaceId: string,
field: "privateReply" | "publicReply",
reply: { type: string; value: string | null } | null | undefined,
tx?: DatabaseClient,
): Promise<void> {
if (reply?.type !== "flow" || !reply.value) {
return
}
const exists = await flowService.exists(workspaceId, reply.value, tx)
if (!exists) {
throw validationException(field, "Flow not found")
}
}

async createMessenger(input: {
workspaceId: string
data: FbCommentAutomationWriteData
}): Promise<CommentAutomationModel> {
await this.assertFlowReplyExists(
input.workspaceId,
"privateReply",
input.data.privateReply,
)
await this.assertFlowReplyExists(
input.workspaceId,
"publicReply",
input.data.publicReply,
)
const [created] = await db
.insert(commentAutomationModel)
.values({
Expand All @@ -533,6 +576,16 @@ class CommentAutomationService extends BaseService {
data: Partial<FbCommentAutomationWriteData>,
): Promise<CommentAutomationModel> {
await this.findMessengerOrFail(ctx)
await this.assertFlowReplyExists(
ctx.workspaceId,
"privateReply",
data.privateReply,
)
await this.assertFlowReplyExists(
ctx.workspaceId,
"publicReply",
data.publicReply,
)

const [updated] = await db
.update(commentAutomationModel)
Expand Down Expand Up @@ -619,6 +672,16 @@ class CommentAutomationService extends BaseService {
type: IgCommentAutomationType
data: FbCommentAutomationWriteData
}): Promise<CommentAutomationModel> {
await this.assertFlowReplyExists(
input.workspaceId,
"privateReply",
input.data.privateReply,
)
await this.assertFlowReplyExists(
input.workspaceId,
"publicReply",
input.data.publicReply,
)
const [created] = await db
.insert(commentAutomationModel)
.values({
Expand All @@ -636,6 +699,16 @@ class CommentAutomationService extends BaseService {
data: Partial<FbCommentAutomationWriteData>,
): Promise<CommentAutomationModel> {
await this.findInstagramOrFail(ctx)
await this.assertFlowReplyExists(
ctx.workspaceId,
"privateReply",
data.privateReply,
)
await this.assertFlowReplyExists(
ctx.workspaceId,
"publicReply",
data.publicReply,
)

const [updated] = await db
.update(commentAutomationModel)
Expand Down Expand Up @@ -735,6 +808,12 @@ class CommentAutomationService extends BaseService {
tx?: DatabaseClient
}) {
const { workspaceId, data, tx = db } = props
await this.assertFlowReplyExists(
workspaceId,
"publicReply",
data.publicReply,
tx,
)
const [record] = await tx
.insert(commentAutomationModel)
.values({
Expand Down Expand Up @@ -764,6 +843,12 @@ class CommentAutomationService extends BaseService {
tx?: DatabaseClient
}) {
const { workspaceId, id, data, tx = db } = props
await this.assertFlowReplyExists(
workspaceId,
"publicReply",
data.publicReply,
tx,
)
const values: Record<string, unknown> = {}

if (data.name !== undefined) {
Expand Down Expand Up @@ -876,6 +961,12 @@ class CommentAutomationService extends BaseService {
tx?: DatabaseClient
}) {
const { workspaceId, data, tx = db } = props
await this.assertFlowReplyExists(
workspaceId,
"publicReply",
data.publicReply,
tx,
)
const [record] = await tx
.insert(commentAutomationModel)
.values({
Expand Down Expand Up @@ -905,6 +996,12 @@ class CommentAutomationService extends BaseService {
tx?: DatabaseClient
}) {
const { workspaceId, id, data, tx = db } = props
await this.assertFlowReplyExists(
workspaceId,
"publicReply",
data.publicReply,
tx,
)
const values: Record<string, unknown> = {}

if (data.name !== undefined) {
Expand Down
Loading