Skip to content

Commit 6b563d1

Browse files
refactor(builder): move ai feature data access into business services (#1096)
* refactor(builder): move ai feature data access into business services Removes direct db usage from the builder AI surface (Claude, DeepSeek, Gemini, OpenAI connect actions and queries, ai-files, ai-functions, ai-mcp-servers, ai-triggers, personas) per .agents/rules/data-access.md. - add connect() to the four LLM provider services (mirrors openrouter) - add aiFileService (create/delete/listWithEmbeddingStatus) and aiTriggerService (list/create/duplicate) - add aiFunctionService.list and integrationMessengerRepository.listPersonasByWorkspaceId - actions/queries become thin wrappers; cache invalidation stays in the builder because business must not import @chatbotx.io/ai - OpenAI connect audits via dispatchAuditRecord with an explicit workspaceId (authActionClient has no workspace in the audit context) * chore: minor changes
1 parent de7fca9 commit 6b563d1

20 files changed

Lines changed: 1249 additions & 262 deletions

File tree

Lines changed: 97 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import { beforeEach, describe, expect, test, vi } from "vitest"
22

33
const mocks = vi.hoisted(() => ({
4-
auditRecord: vi.fn(),
5-
deleteObject: vi.fn(),
6-
findFirstGemini: vi.fn(),
7-
findFirstOpenai: vi.fn(),
8-
findOrFail: vi.fn(),
9-
insertReturning: vi.fn(),
10-
loggerWarn: vi.fn(),
11-
queueAdd: vi.fn(),
12-
txDeleteWhere: vi.fn(),
4+
create: vi.fn(),
5+
delete: vi.fn(),
136
}))
147

158
vi.mock("@/lib/safe-action", () => {
@@ -24,33 +17,35 @@ vi.mock("@/features/common/schema", () => ({
2417
workspaceIdrequestParams: [],
2518
}))
2619

27-
vi.mock("@/lib/log", () => ({
28-
logger: { warn: mocks.loggerWarn },
29-
}))
30-
31-
vi.mock("@chatbotx.io/business/audit", () => ({
32-
auditService: { record: mocks.auditRecord },
20+
vi.mock("@chatbotx.io/business", () => ({
21+
aiFileService: {
22+
create: mocks.create,
23+
delete: mocks.delete,
24+
},
3325
}))
3426

3527
vi.mock("@chatbotx.io/business/errors", () => ({
36-
ChatbotXException: class ChatbotXException extends Error {},
37-
}))
38-
39-
vi.mock("@chatbotx.io/filesystem", () => ({
40-
uploader: { deleteObject: mocks.deleteObject },
28+
ChatbotXException: class ChatbotXException extends Error {
29+
code = "systemError"
30+
httpStatusCode = 400
31+
32+
constructor(message: string, code?: string, httpStatusCode?: number) {
33+
super(message)
34+
this.name = "ChatbotXException"
35+
if (code) {
36+
this.code = code
37+
}
38+
if (httpStatusCode) {
39+
this.httpStatusCode = httpStatusCode
40+
}
41+
}
42+
},
4143
}))
4244

4345
vi.mock("@chatbotx.io/utils", () => ({
44-
createId: () => "file-1",
4546
zodBigintAsString: () => "mocked-schema",
4647
}))
4748

48-
vi.mock("@chatbotx.io/worker-config", () => ({
49-
HeavyJobAction: { processAIFile: "processAIFile" },
50-
getHeavyJobOptions: () => ({}),
51-
heavyQueue: { add: mocks.queueAdd },
52-
}))
53-
5449
vi.mock("next-intl/server", () => ({
5550
getTranslations: vi.fn(async () => (key: string) => key),
5651
}))
@@ -59,33 +54,11 @@ vi.mock("../src/features/ai-files/schema", () => ({
5954
createAIFileRequest: {},
6055
}))
6156

62-
vi.mock("@chatbotx.io/database/schema", () => ({
63-
aiEmbeddingModel: { id: "id" },
64-
aiFileModel: { id: "id" },
65-
}))
66-
67-
vi.mock("@chatbotx.io/database/client", () => ({
68-
db: {
69-
delete: vi.fn(() => ({ where: mocks.txDeleteWhere })),
70-
insert: vi.fn(() => ({
71-
values: vi.fn(() => ({ returning: mocks.insertReturning })),
72-
})),
73-
query: {
74-
integrationGeminiModel: { findFirst: mocks.findFirstGemini },
75-
integrationOpenaiModel: { findFirst: mocks.findFirstOpenai },
76-
},
77-
transaction: vi.fn(async (callback: (tx: unknown) => unknown) =>
78-
callback({ delete: vi.fn(() => ({ where: mocks.txDeleteWhere })) }),
79-
),
80-
},
81-
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
82-
findOrFail: mocks.findOrFail,
83-
}))
84-
57+
const { ChatbotXException } = await import("@chatbotx.io/business/errors")
8558
const { createAIFileAction } = await import(
8659
"@/features/ai-files/actions/create-ai-file.action"
8760
)
88-
const { deleteAIFile } = await import(
61+
const { deleteAIFileAction } = await import(
8962
"@/features/ai-files/actions/delete-ai-file.action"
9063
)
9164

@@ -98,74 +71,97 @@ const workspaceId = "workspace-1"
9871

9972
beforeEach(() => {
10073
vi.clearAllMocks()
101-
mocks.findFirstOpenai.mockResolvedValue({ id: "openai-1" })
102-
mocks.findFirstGemini.mockResolvedValue(undefined)
103-
mocks.insertReturning.mockResolvedValue([{ id: "file-1" }])
104-
mocks.findOrFail.mockResolvedValue({ id: "file-1", path: "path/to/file" })
10574
})
10675

107-
describe("Knowledge tab audit messages", () => {
108-
test("allows creating a Knowledge with Gemini as the only provider", async () => {
109-
mocks.findFirstOpenai.mockResolvedValue(undefined)
110-
mocks.findFirstGemini.mockResolvedValue({ id: "gemini-1" })
111-
112-
await (
113-
createAIFileAction as unknown as ActionHandler<{ name: string }, [string]>
114-
)({
115-
parsedInput: { name: "manual.pdf" },
116-
bindArgsParsedInputs: [workspaceId],
117-
})
118-
119-
expect(mocks.insertReturning).toHaveBeenCalled()
120-
expect(mocks.queueAdd).toHaveBeenCalled()
121-
})
76+
describe("createAIFileAction", () => {
77+
test("forwards workspaceId + parsedInput to aiFileService.create", async () => {
78+
mocks.create.mockResolvedValue({ id: "file-1" })
12279

123-
test("createAIFileAction logs created a new Knowledge by id", async () => {
12480
await (
125-
createAIFileAction as unknown as ActionHandler<{ name: string }, [string]>
81+
createAIFileAction as unknown as ActionHandler<
82+
{ name: string; path: string; mimeType: string; size: number },
83+
[string]
84+
>
12685
)({
127-
parsedInput: { name: "manual.pdf" },
86+
parsedInput: {
87+
name: "manual.pdf",
88+
path: "path/to/file",
89+
mimeType: "application/pdf",
90+
size: 100,
91+
},
12892
bindArgsParsedInputs: [workspaceId],
12993
})
13094

131-
expect(mocks.auditRecord).toHaveBeenCalledWith({
95+
expect(mocks.create).toHaveBeenCalledWith({
13296
workspaceId,
133-
action: "create",
134-
detail: "created a new Knowledge (#file-1)",
97+
name: "manual.pdf",
98+
path: "path/to/file",
99+
mimeType: "application/pdf",
100+
size: 100,
135101
})
136-
expect(mocks.queueAdd).toHaveBeenCalledWith(
137-
"processAIFile",
138-
{
139-
type: "processAIFile",
140-
data: { aiFileId: "file-1" },
141-
},
142-
{ jobId: "heavy-ai-file-file-1" },
143-
)
144102
})
145103

146-
test("deleteAIFile logs deleted a Knowledge by id", async () => {
147-
await deleteAIFile({ workspaceId, id: "file-1" })
104+
test("translates a noEmbeddingProvider service error", async () => {
105+
mocks.create.mockRejectedValue(
106+
new ChatbotXException(
107+
"AI file requires an embedding provider",
108+
"noEmbeddingProvider",
109+
400,
110+
),
111+
)
148112

149-
expect(mocks.auditRecord).toHaveBeenCalledWith({
150-
workspaceId,
151-
action: "delete",
152-
detail: "deleted a Knowledge (#file-1)",
153-
})
113+
await expect(
114+
(
115+
createAIFileAction as unknown as ActionHandler<
116+
{ name: string; path: string; mimeType: string; size: number },
117+
[string]
118+
>
119+
)({
120+
parsedInput: {
121+
name: "manual.pdf",
122+
path: "path/to/file",
123+
mimeType: "application/pdf",
124+
size: 100,
125+
},
126+
bindArgsParsedInputs: [workspaceId],
127+
}),
128+
).rejects.toMatchObject({ message: "noEmbeddingProvider" })
129+
})
130+
131+
test("rethrows other service errors untranslated", async () => {
132+
mocks.create.mockRejectedValue(new Error("db exploded"))
133+
134+
await expect(
135+
(
136+
createAIFileAction as unknown as ActionHandler<
137+
{ name: string; path: string; mimeType: string; size: number },
138+
[string]
139+
>
140+
)({
141+
parsedInput: {
142+
name: "manual.pdf",
143+
path: "path/to/file",
144+
mimeType: "application/pdf",
145+
size: 100,
146+
},
147+
bindArgsParsedInputs: [workspaceId],
148+
}),
149+
).rejects.toMatchObject({ message: "db exploded" })
154150
})
151+
})
155152

156-
test("does not log the legacy AI Agent knowledge base message", async () => {
153+
describe("deleteAIFileAction", () => {
154+
test("forwards workspaceId + id to aiFileService.delete", async () => {
157155
await (
158-
createAIFileAction as unknown as ActionHandler<{ name: string }, [string]>
156+
deleteAIFileAction as unknown as ActionHandler<
157+
undefined,
158+
[string, string]
159+
>
159160
)({
160-
parsedInput: { name: "manual.pdf" },
161-
bindArgsParsedInputs: [workspaceId],
161+
parsedInput: undefined,
162+
bindArgsParsedInputs: [workspaceId, "file-1"],
162163
})
163-
await deleteAIFile({ workspaceId, id: "file-1" })
164164

165-
for (const call of mocks.auditRecord.mock.calls) {
166-
expect(call[0].detail).not.toContain(
167-
"updated the AI Agent knowledge base",
168-
)
169-
}
165+
expect(mocks.delete).toHaveBeenCalledWith({ workspaceId, id: "file-1" })
170166
})
171167
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest"
2+
3+
// ---------------------------------------------------------------------------
4+
// AI Triggers create/duplicate/list actions — thin wrappers delegating to
5+
// aiTriggerService. `list` still gates on assertCurrentUserCanAccessChatbot
6+
// before calling the service.
7+
// ---------------------------------------------------------------------------
8+
9+
const mocks = vi.hoisted(() => ({
10+
assertCurrentUserCanAccessChatbot: vi.fn(),
11+
create: vi.fn(),
12+
duplicate: vi.fn(),
13+
list: vi.fn(),
14+
}))
15+
16+
vi.mock("@/lib/safe-action", () => {
17+
const chain: Record<string, unknown> = {}
18+
chain.bindArgsSchemas = () => chain
19+
chain.inputSchema = () => chain
20+
chain.action = (fn: unknown) => fn
21+
return { workspaceActionClient: chain }
22+
})
23+
24+
vi.mock("@/features/common/schema", () => ({
25+
workspaceIdrequestParams: [],
26+
}))
27+
28+
vi.mock("@/features/ai-triggers/schema/action", () => ({
29+
createAITriggerRequest: {},
30+
}))
31+
32+
vi.mock("@/lib/auth/utils", () => ({
33+
assertCurrentUserCanAccessChatbot: mocks.assertCurrentUserCanAccessChatbot,
34+
}))
35+
36+
vi.mock("@chatbotx.io/utils", () => ({
37+
zodBigintAsString: () => "mocked-schema",
38+
}))
39+
40+
vi.mock("@chatbotx.io/business", () => ({
41+
aiTriggerService: {
42+
create: mocks.create,
43+
duplicate: mocks.duplicate,
44+
list: mocks.list,
45+
},
46+
}))
47+
48+
const { createAITriggerAction } = await import(
49+
"@/features/ai-triggers/actions/create.action"
50+
)
51+
const { duplicateAITriggerAction } = await import(
52+
"@/features/ai-triggers/actions/duplicate.action"
53+
)
54+
const { listAITriggers } = await import(
55+
"@/features/ai-triggers/actions/list.action"
56+
)
57+
58+
type ActionHandler<TParsedInput, TBindArgs extends unknown[]> = (props: {
59+
parsedInput: TParsedInput
60+
bindArgsParsedInputs: TBindArgs
61+
}) => Promise<unknown>
62+
63+
const workspaceId = "workspace-1"
64+
65+
beforeEach(() => {
66+
vi.clearAllMocks()
67+
})
68+
69+
describe("createAITriggerAction", () => {
70+
test("delegates to aiTriggerService.create with workspaceId + parsedInput", async () => {
71+
const parsedInput = {
72+
name: "New trigger",
73+
description: null,
74+
questions: [],
75+
flowId: null,
76+
finalMessage: null,
77+
}
78+
79+
await (
80+
createAITriggerAction as unknown as ActionHandler<
81+
typeof parsedInput,
82+
[string]
83+
>
84+
)({
85+
parsedInput,
86+
bindArgsParsedInputs: [workspaceId],
87+
})
88+
89+
expect(mocks.create).toHaveBeenCalledWith({
90+
workspaceId,
91+
data: parsedInput,
92+
})
93+
})
94+
})
95+
96+
describe("duplicateAITriggerAction", () => {
97+
test("delegates to aiTriggerService.duplicate with workspaceId + id", async () => {
98+
await (
99+
duplicateAITriggerAction as unknown as ActionHandler<
100+
undefined,
101+
[string, string]
102+
>
103+
)({
104+
parsedInput: undefined,
105+
bindArgsParsedInputs: [workspaceId, "trigger-1"],
106+
})
107+
108+
expect(mocks.duplicate).toHaveBeenCalledWith({
109+
workspaceId,
110+
id: "trigger-1",
111+
})
112+
})
113+
})
114+
115+
describe("listAITriggers", () => {
116+
test("asserts chatbot access then delegates to aiTriggerService.list", async () => {
117+
const input = {
118+
workspaceId,
119+
page: 1,
120+
perPage: 20,
121+
sort: [],
122+
name: "",
123+
}
124+
mocks.list.mockResolvedValue({ data: [], pageCount: 0 })
125+
126+
const result = await listAITriggers(input)
127+
128+
expect(mocks.assertCurrentUserCanAccessChatbot).toHaveBeenCalledWith(
129+
workspaceId,
130+
)
131+
expect(mocks.list).toHaveBeenCalledWith(input)
132+
expect(result).toEqual({ data: [], pageCount: 0 })
133+
})
134+
})

0 commit comments

Comments
 (0)