Skip to content

Commit 485795e

Browse files
feat(audit-log): add audit logging service and admin audit log page
Records admin actions (create/update/delete/disconnect, etc.) across the app and worker layers via a workspace-scoped AsyncLocalStorage actor, dispatched through a queued job, with an admin-facing page to browse and filter the log.
1 parent 86fb831 commit 485795e

97 files changed

Lines changed: 19473 additions & 334 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/feature-scaffold/SKILL.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,47 @@ Full-page create/edit forms follow this layout:
275275
</div>
276276
```
277277

278+
### Interactive Elements — Use `<Button>`
279+
280+
**Never use a raw `<button>` element.** Always use `Button` from `@chatbotx.io/ui/components/ui/button` — including icon-only buttons, buttons wrapped in a `group`/`hover` container, and buttons rendered inside a base-ui trigger (`DropdownMenuTrigger`, `DialogClose`, etc. via their `render` prop).
281+
282+
```typescript
283+
import { Button } from "@chatbotx.io/ui/components/ui/button"
284+
285+
// WRONG — raw HTML button
286+
<button className="flex items-center gap-2 rounded-md px-3 py-2" onClick={onClick} type="button">
287+
<PlusIcon className="size-4" />
288+
{t("actions.create")}
289+
</button>
290+
291+
// CORRECT — Button component
292+
<Button onClick={onClick} type="button" variant="ghost">
293+
<PlusIcon className="size-4" />
294+
{t("actions.create")}
295+
</Button>
296+
```
297+
298+
Variants: `default`, `destructive`, `outline`, `secondary`, `ghost`, `link`, `dashed`. Sizes: `default`, `sm`, `lg`, `icon` (use `icon` for icon-only buttons, not a text-labelled size with padding overrides).
299+
300+
For a base-ui trigger that needs a custom element (`DropdownMenuTrigger`, `DialogClose`, `TooltipTrigger`), pass `<Button>` via the `render` prop instead of a raw `<button>`:
301+
302+
```typescript
303+
<DropdownMenuTrigger
304+
render={
305+
<Button size="icon" type="button" variant="ghost">
306+
<MoreVerticalIcon className="size-3.5" />
307+
</Button>
308+
}
309+
/>
310+
```
311+
312+
`Button` renders as `inline-flex items-center justify-center` with size-driven height/padding (`buttonVariants` in `packages/ui/src/components/ui/button.tsx`). When reusing it for a non-standard layout (e.g. a full-width nav item, or a card that stacks children vertically), override what doesn't fit instead of fighting it with a raw `<button>`:
313+
314+
- Full-width, left-aligned row (sidebar nav item, list row): add `justify-start`.
315+
- Vertically stacked children (a clickable card): add `flex-col items-stretch justify-start h-auto gap-0``Button`'s base classes lay out children in a row by default.
316+
- Compact icon-only button that shouldn't be a fixed 36px square: use `size="icon"` and override with `size-auto` (or an explicit `size-*`) plus your own padding.
317+
- A `bg-primary`/active-state button must also override `hover:bg-primary` (or similar) — `variant="ghost"`'s default `hover:bg-accent` will otherwise flash a different color on hover while the button is in its active/selected state.
318+
278319
### CRITICAL — Default Values for Nullable Text Fields
279320

280321
React Hook Form requires non-null values for controlled text inputs. Passing `null` as a `defaultValues` entry causes React to treat the input as **uncontrolled**, triggering warnings and unpredictable behavior.

apps/builder/__tests__/create-message-action.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @vitest-environment node
22

33
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
import { z } from "zod"
45

56
const {
67
mockChatQueueAdd,
@@ -84,6 +85,20 @@ vi.mock("@chatbotx.io/database/schema", () => ({
8485
firstInteractionAt: "firstInteractionAt",
8586
},
8687
conversationModel: { id: "conversationId" },
88+
mediaLibraryFileModel: {},
89+
mediaLibraryFolderModel: {},
90+
// media-library/queries/files.ts (pulled in transitively via
91+
// findMediaLibraryFileByPath) imports ../schemas, which calls
92+
// createSelectSchema(...).extend(...) at module scope.
93+
createSelectSchema: () => z.object({}),
94+
}))
95+
96+
// create-message.action.ts now imports findMediaLibraryFileByPath, which
97+
// transitively imports the real @/lib/auth/utils -> auth.ts -> server.ts
98+
// (createAuth) unless mocked, and that needs a userModel export this mock
99+
// doesn't provide.
100+
vi.mock("@/lib/auth/utils", () => ({
101+
assertCurrentUserCanAccessChatbot: vi.fn().mockResolvedValue(undefined),
87102
}))
88103

89104
vi.mock("@chatbotx.io/filesystem", () => ({
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
// Every media-library action is a one-line `workspaceActionClient
6+
// .bindArgsSchemas(...).inputSchema(...).action(handler)` wrapper — the
7+
// business logic lives in `queries/mutations`. Mocking `.action()` to return
8+
// the handler itself (instead of the real next-safe-action runtime) lets us
9+
// call the exported `xxxAction` directly with `{ bindArgsParsedInputs,
10+
// parsedInput }`, exercising exactly the wiring this file is responsible
11+
// for: does the handler bind `workspaceId` correctly and delegate to the
12+
// right mutation.
13+
vi.mock("@/lib/safe-action", () => {
14+
const chain: Record<string, unknown> = {}
15+
chain.bindArgsSchemas = vi.fn(() => chain)
16+
chain.inputSchema = vi.fn(() => chain)
17+
chain.action = vi.fn((handler: unknown) => handler)
18+
return { workspaceActionClient: chain }
19+
})
20+
21+
vi.mock("@/features/media-library/queries/mutations", () => ({
22+
createMediaLibraryFolder: vi.fn(),
23+
renameMediaLibraryFolder: vi.fn(),
24+
deleteMediaLibraryFolder: vi.fn(),
25+
createMediaLibraryFile: vi.fn(),
26+
deleteMediaLibraryFile: vi.fn(),
27+
moveMediaLibraryFiles: vi.fn(),
28+
toggleMediaLibraryFavourite: vi.fn(),
29+
recordMediaLibraryFileAccess: vi.fn(),
30+
}))
31+
32+
const {
33+
createMediaLibraryFolder,
34+
renameMediaLibraryFolder,
35+
deleteMediaLibraryFolder,
36+
createMediaLibraryFile,
37+
deleteMediaLibraryFile,
38+
moveMediaLibraryFiles,
39+
toggleMediaLibraryFavourite,
40+
recordMediaLibraryFileAccess,
41+
} = await import("../src/features/media-library/queries/mutations")
42+
43+
const { createMediaLibraryFolderAction } = await import(
44+
"../src/features/media-library/actions/create-folder.action"
45+
)
46+
const { renameMediaLibraryFolderAction } = await import(
47+
"../src/features/media-library/actions/rename-folder.action"
48+
)
49+
const { deleteMediaLibraryFolderAction } = await import(
50+
"../src/features/media-library/actions/delete-folder.action"
51+
)
52+
const { createMediaLibraryFileAction } = await import(
53+
"../src/features/media-library/actions/create-file.action"
54+
)
55+
const { deleteMediaLibraryFileAction } = await import(
56+
"../src/features/media-library/actions/delete-file.action"
57+
)
58+
const { moveMediaLibraryFilesAction } = await import(
59+
"../src/features/media-library/actions/move-files.action"
60+
)
61+
const { toggleMediaLibraryFavouriteAction } = await import(
62+
"../src/features/media-library/actions/toggle-favourite.action"
63+
)
64+
const { recordMediaLibraryFileAccessAction } = await import(
65+
"../src/features/media-library/actions/record-access.action"
66+
)
67+
68+
const WS = "workspace-1"
69+
70+
beforeEach(() => {
71+
vi.clearAllMocks()
72+
})
73+
74+
describe("createMediaLibraryFolderAction", () => {
75+
test("binds workspaceId from bindArgsParsedInputs and forwards the name", async () => {
76+
vi.mocked(createMediaLibraryFolder).mockResolvedValue(
77+
{} as Awaited<ReturnType<typeof createMediaLibraryFolder>>,
78+
)
79+
80+
await (
81+
createMediaLibraryFolderAction as unknown as (input: {
82+
bindArgsParsedInputs: [string]
83+
parsedInput: { name: string }
84+
}) => Promise<unknown>
85+
)({ bindArgsParsedInputs: [WS], parsedInput: { name: "Marketing" } })
86+
87+
expect(createMediaLibraryFolder).toHaveBeenCalledWith({
88+
workspaceId: WS,
89+
name: "Marketing",
90+
})
91+
})
92+
})
93+
94+
describe("renameMediaLibraryFolderAction", () => {
95+
test("binds workspaceId and forwards folderId + name", async () => {
96+
await (
97+
renameMediaLibraryFolderAction as unknown as (input: {
98+
bindArgsParsedInputs: [string]
99+
parsedInput: { folderId: string; name: string }
100+
}) => Promise<unknown>
101+
)({
102+
bindArgsParsedInputs: [WS],
103+
parsedInput: { folderId: "folder-1", name: "Renamed" },
104+
})
105+
106+
expect(renameMediaLibraryFolder).toHaveBeenCalledWith({
107+
workspaceId: WS,
108+
folderId: "folder-1",
109+
name: "Renamed",
110+
})
111+
})
112+
})
113+
114+
describe("deleteMediaLibraryFolderAction", () => {
115+
test("binds workspaceId and forwards the bare folderId input", async () => {
116+
await (
117+
deleteMediaLibraryFolderAction as unknown as (input: {
118+
bindArgsParsedInputs: [string]
119+
parsedInput: string
120+
}) => Promise<unknown>
121+
)({ bindArgsParsedInputs: [WS], parsedInput: "folder-1" })
122+
123+
expect(deleteMediaLibraryFolder).toHaveBeenCalledWith({
124+
workspaceId: WS,
125+
folderId: "folder-1",
126+
})
127+
})
128+
})
129+
130+
describe("createMediaLibraryFileAction", () => {
131+
test("binds workspaceId and spreads the parsed file input", async () => {
132+
vi.mocked(createMediaLibraryFile).mockResolvedValue(
133+
{} as Awaited<ReturnType<typeof createMediaLibraryFile>>,
134+
)
135+
136+
await (
137+
createMediaLibraryFileAction as unknown as (input: {
138+
bindArgsParsedInputs: [string]
139+
parsedInput: {
140+
folderId: string | null
141+
name: string
142+
path: string
143+
mimeType: string
144+
size: number
145+
}
146+
}) => Promise<unknown>
147+
)({
148+
bindArgsParsedInputs: [WS],
149+
parsedInput: {
150+
folderId: null,
151+
name: "logo.png",
152+
path: "ws/1/logo.png",
153+
mimeType: "image/png",
154+
size: 1024,
155+
},
156+
})
157+
158+
expect(createMediaLibraryFile).toHaveBeenCalledWith({
159+
workspaceId: WS,
160+
folderId: null,
161+
name: "logo.png",
162+
path: "ws/1/logo.png",
163+
mimeType: "image/png",
164+
size: 1024,
165+
})
166+
})
167+
})
168+
169+
describe("deleteMediaLibraryFileAction", () => {
170+
test("binds workspaceId and forwards the bare fileId input", async () => {
171+
await (
172+
deleteMediaLibraryFileAction as unknown as (input: {
173+
bindArgsParsedInputs: [string]
174+
parsedInput: string
175+
}) => Promise<unknown>
176+
)({ bindArgsParsedInputs: [WS], parsedInput: "file-1" })
177+
178+
expect(deleteMediaLibraryFile).toHaveBeenCalledWith({
179+
workspaceId: WS,
180+
fileId: "file-1",
181+
})
182+
})
183+
})
184+
185+
describe("moveMediaLibraryFilesAction", () => {
186+
test("binds workspaceId and forwards fileIds + folderId", async () => {
187+
await (
188+
moveMediaLibraryFilesAction as unknown as (input: {
189+
bindArgsParsedInputs: [string]
190+
parsedInput: { fileIds: string[]; folderId: string | null }
191+
}) => Promise<unknown>
192+
)({
193+
bindArgsParsedInputs: [WS],
194+
parsedInput: { fileIds: ["file-1", "file-2"], folderId: "folder-9" },
195+
})
196+
197+
expect(moveMediaLibraryFiles).toHaveBeenCalledWith({
198+
workspaceId: WS,
199+
fileIds: ["file-1", "file-2"],
200+
folderId: "folder-9",
201+
})
202+
})
203+
})
204+
205+
describe("toggleMediaLibraryFavouriteAction", () => {
206+
test("binds workspaceId and forwards the bare fileId input", async () => {
207+
await (
208+
toggleMediaLibraryFavouriteAction as unknown as (input: {
209+
bindArgsParsedInputs: [string]
210+
parsedInput: string
211+
}) => Promise<unknown>
212+
)({ bindArgsParsedInputs: [WS], parsedInput: "file-1" })
213+
214+
expect(toggleMediaLibraryFavourite).toHaveBeenCalledWith({
215+
workspaceId: WS,
216+
fileId: "file-1",
217+
})
218+
})
219+
})
220+
221+
describe("recordMediaLibraryFileAccessAction", () => {
222+
test("binds workspaceId and forwards the bare fileId input", async () => {
223+
await (
224+
recordMediaLibraryFileAccessAction as unknown as (input: {
225+
bindArgsParsedInputs: [string]
226+
parsedInput: string
227+
}) => Promise<unknown>
228+
)({ bindArgsParsedInputs: [WS], parsedInput: "file-1" })
229+
230+
expect(recordMediaLibraryFileAccess).toHaveBeenCalledWith({
231+
workspaceId: WS,
232+
fileId: "file-1",
233+
})
234+
})
235+
})

apps/builder/__tests__/message-item-avatar.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ vi.mock("next-intl", () => ({
99
useTranslations: () => (key: string) => key,
1010
}))
1111

12+
// MessageItem -> MessageActions -> MediaLibraryTrigger, which imports its
13+
// `"use server"` query modules at module scope. Next.js rewrites those to RPC
14+
// stubs, but vitest evaluates them for real, dragging in the
15+
// `@chatbotx.io/business` barrel and a live pg Pool — which throws on
16+
// `env.DATABASE_URL` under the browser-conditions preset. Stubbing the
17+
// trigger cuts that chain and keeps this test about avatar gating.
18+
vi.mock("@/features/media-library/components/media-library-trigger", () => ({
19+
MediaLibraryTrigger: () => null,
20+
}))
21+
1222
// Base UI's Avatar.Image only mounts once the underlying <img> actually
1323
// fires a load/error event (see useImageLoadingStatus), which jsdom never
1424
// dispatches for a src that isn't really fetched. Mock it down to plain

apps/builder/messages/ar.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"reset": "إعادة تعيين",
7272
"save": "حفظ",
7373
"selectAll": "تحديد الكل",
74+
"unselect": "إلغاء التحديد",
7475
"selectRow": "تحديد الصف",
7576
"sendFlow": "إرسال المسار",
7677
"sendMessage": "إرسال رسالة",
@@ -2908,6 +2909,8 @@
29082909
"editMessagePlaceholder": "عدّل رسالتك...",
29092910
"saveEdit": "حفظ",
29102911
"cancelEdit": "إلغاء",
2912+
"replaceAttachment": "استبدال المرفق",
2913+
"addAttachment": "إضافة مرفق",
29112914
"failedToCopy": "تعذّر النسخ",
29122915
"failedToPreviewImage": "تعذّرت معاينة الصورة",
29132916
"loadingData": "جارٍ تحميل البيانات...",
@@ -5430,5 +5433,32 @@
54305433
"empty": "لا توجد سجلات لعب بعد"
54315434
}
54325435
}
5436+
},
5437+
"mediaLibrary": {
5438+
"title": "مكتبة الوسائط",
5439+
"searchPlaceholder": "البحث عن الملفات...",
5440+
"newFolder": "مجلد جديد",
5441+
"recent": "الأحدث",
5442+
"favourite": "المفضلة",
5443+
"upload": "رفع",
5444+
"done": "تم",
5445+
"deleteFolder": "حذف المجلد",
5446+
"renameFolder": "إعادة تسمية المجلد",
5447+
"noFiles": "لا توجد ملفات",
5448+
"noFolders": "لا توجد مجلدات بعد",
5449+
"folderNamePlaceholder": "اسم المجلد",
5450+
"confirmDeleteFolder": "حذف المجلد وجميع ملفاته؟",
5451+
"confirmDeleteFolderDescription": "سيتم حذف جميع الملفات في هذا المجلد نهائيًا من الخادم. لا يمكن التراجع عن هذا الإجراء.",
5452+
"confirmDeleteFile": "حذف الملف؟",
5453+
"confirmDeleteFileDescription": "سيتم حذف هذا الملف نهائيًا من الخادم.",
5454+
"addToFavourites": "إضافة إلى المفضلة",
5455+
"removeFromFavourites": "إزالة من المفضلة",
5456+
"deleteFile": "حذف الملف",
5457+
"selectOnlyOneFile": "يمكنك تحديد ملف واحد فقط",
5458+
"confirmDeleteFiles": "{count, plural, =1 {حذف ملف واحد؟} other {حذف # ملفات؟}}",
5459+
"confirmDeleteFilesDescription": "سيتم حذف هذه الملفات نهائيًا من الخادم. لا يمكن التراجع عن هذا الإجراء.",
5460+
"filesCount": "{count, plural, =0 {لا توجد ملفات} =1 {ملف واحد} other {# ملفات}}",
5461+
"openMediaLibrary": "فتح مكتبة الوسائط",
5462+
"uploadFailed": "فشل رفع {name}"
54335463
}
54345464
}

0 commit comments

Comments
 (0)