diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index e84b9a20..955f3049 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1050,5 +1050,52 @@
"backlinks.untitled": "Untitled",
"wikilink.suggestion.no_results": "No matching pages",
"wikilink.suggestion.type_to_search": "Type to search pages...",
- "wikilink.broken": "Page not found or deleted"
+ "wikilink.broken": "Page not found or deleted",
+ "slash_commands.page_title": "Slash commands",
+ "slash_commands.create_button": "New command",
+ "slash_commands.create_title": "Create slash command",
+ "slash_commands.edit_title": "Edit slash command",
+ "slash_commands.col_keyword": "Keyword",
+ "slash_commands.col_label": "Label",
+ "slash_commands.col_action_type": "Action type",
+ "slash_commands.col_enabled": "Enabled",
+ "slash_commands.keyword_label": "Keyword",
+ "slash_commands.keyword_description": "Lowercase letters, numbers and hyphens only. Used as /keyword in the editor.",
+ "slash_commands.keyword_format_error": "Only lowercase letters, numbers and hyphens are allowed",
+ "slash_commands.label_label": "Label",
+ "slash_commands.label_required": "Label is required",
+ "slash_commands.description_label": "Description",
+ "slash_commands.description_placeholder": "Short description shown in the slash menu",
+ "slash_commands.icon_label": "Icon",
+ "slash_commands.icon_description": "Tabler icon name (e.g. IconNotes) or leave blank",
+ "slash_commands.action_type_label": "Action type",
+ "slash_commands.action_config_section": "Action configuration",
+ "slash_commands.enabled_label": "Enabled",
+ "slash_commands.template_label": "Template content",
+ "slash_commands.template_description": "Markdown text or Tiptap JSON to insert at cursor",
+ "slash_commands.rows_label": "Rows",
+ "slash_commands.cols_label": "Columns",
+ "slash_commands.header_row_label": "Include header row",
+ "slash_commands.url_label": "URL to embed",
+ "slash_commands.url_required": "A valid URL starting with http is required",
+ "slash_commands.webhook_url_label": "Webhook URL",
+ "slash_commands.webhook_https_required": "Webhook URL must start with https://",
+ "slash_commands.webhook_headers_label": "Additional headers (JSON)",
+ "slash_commands.webhook_headers_description": "Optional JSON object of extra HTTP headers to send",
+ "slash_commands.webhook_security_title": "Security note",
+ "slash_commands.webhook_security_note": "Never include secrets in stored headers. Use a secret-manager proxy in front of your webhook endpoint.",
+ "slash_commands.language_label": "Code language",
+ "slash_commands.language_required": "Language is required",
+ "slash_commands.snippet_code_label": "Starter code",
+ "slash_commands.snippet_code_description": "Optional starter code inserted with the snippet",
+ "slash_commands.enable_tooltip": "Enable this command",
+ "slash_commands.disable_tooltip": "Disable this command",
+ "slash_commands.delete_confirm": "Delete slash command \"{{label}}\"? This cannot be undone.",
+ "slash_commands.create_success": "Slash command created",
+ "slash_commands.update_success": "Slash command updated",
+ "slash_commands.delete_success": "Slash command deleted",
+ "slash_commands.load_error": "Could not load slash commands",
+ "slash_commands.empty_state": "No custom slash commands yet. Create one to get started.",
+ "slash_commands.access_denied_title": "Access denied",
+ "slash_commands.access_denied_description": "You need the slash_commands:manage permission to access this page."
}
\ No newline at end of file
diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json
index eeff635b..9370a282 100644
--- a/apps/client/public/locales/fr-FR/translation.json
+++ b/apps/client/public/locales/fr-FR/translation.json
@@ -1005,5 +1005,52 @@
"backlinks.untitled": "Sans titre",
"wikilink.suggestion.no_results": "Aucune page correspondante",
"wikilink.suggestion.type_to_search": "Tapez pour rechercher des pages...",
- "wikilink.broken": "Page introuvable ou supprimée"
+ "wikilink.broken": "Page introuvable ou supprimée",
+ "slash_commands.page_title": "Commandes slash",
+ "slash_commands.create_button": "Nouvelle commande",
+ "slash_commands.create_title": "Créer une commande slash",
+ "slash_commands.edit_title": "Modifier la commande slash",
+ "slash_commands.col_keyword": "Mot-clé",
+ "slash_commands.col_label": "Libellé",
+ "slash_commands.col_action_type": "Type d'action",
+ "slash_commands.col_enabled": "Activée",
+ "slash_commands.keyword_label": "Mot-clé",
+ "slash_commands.keyword_description": "Lettres minuscules, chiffres et tirets uniquement. Utilisé comme /mot-clé dans l'éditeur.",
+ "slash_commands.keyword_format_error": "Seuls les lettres minuscules, chiffres et tirets sont autorisés",
+ "slash_commands.label_label": "Libellé",
+ "slash_commands.label_required": "Le libellé est requis",
+ "slash_commands.description_label": "Description",
+ "slash_commands.description_placeholder": "Description courte affichée dans le menu slash",
+ "slash_commands.icon_label": "Icône",
+ "slash_commands.icon_description": "Nom d'icône Tabler (ex: IconNotes) ou laisser vide",
+ "slash_commands.action_type_label": "Type d'action",
+ "slash_commands.action_config_section": "Configuration de l'action",
+ "slash_commands.enabled_label": "Activée",
+ "slash_commands.template_label": "Contenu du template",
+ "slash_commands.template_description": "Texte Markdown ou JSON Tiptap à insérer au curseur",
+ "slash_commands.rows_label": "Lignes",
+ "slash_commands.cols_label": "Colonnes",
+ "slash_commands.header_row_label": "Inclure une ligne d'en-tête",
+ "slash_commands.url_label": "URL à intégrer",
+ "slash_commands.url_required": "Une URL valide commençant par http est requise",
+ "slash_commands.webhook_url_label": "URL du webhook",
+ "slash_commands.webhook_https_required": "L'URL du webhook doit commencer par https://",
+ "slash_commands.webhook_headers_label": "En-têtes supplémentaires (JSON)",
+ "slash_commands.webhook_headers_description": "Objet JSON optionnel d'en-têtes HTTP supplémentaires",
+ "slash_commands.webhook_security_title": "Note de sécurité",
+ "slash_commands.webhook_security_note": "Ne jamais inclure de secrets dans les en-têtes stockés. Utilisez un proxy gestionnaire de secrets devant votre endpoint webhook.",
+ "slash_commands.language_label": "Langage du code",
+ "slash_commands.language_required": "Le langage est requis",
+ "slash_commands.snippet_code_label": "Code de départ",
+ "slash_commands.snippet_code_description": "Code optionnel inséré avec le snippet",
+ "slash_commands.enable_tooltip": "Activer cette commande",
+ "slash_commands.disable_tooltip": "Désactiver cette commande",
+ "slash_commands.delete_confirm": "Supprimer la commande slash \"{{label}}\" ? Cette action est irréversible.",
+ "slash_commands.create_success": "Commande slash créée",
+ "slash_commands.update_success": "Commande slash mise à jour",
+ "slash_commands.delete_success": "Commande slash supprimée",
+ "slash_commands.load_error": "Impossible de charger les commandes slash",
+ "slash_commands.empty_state": "Aucune commande slash personnalisée pour l'instant. Créez-en une pour commencer.",
+ "slash_commands.access_denied_title": "Accès refusé",
+ "slash_commands.access_denied_description": "Vous avez besoin de la permission slash_commands:manage pour accéder à cette page."
}
\ No newline at end of file
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index f07a02c6..3fedbe39 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -48,6 +48,8 @@ import VerifyEmail from "@/ee/pages/verify-email.tsx";
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel";
+// Acadenice R3.3 — custom slash commands admin page
+import SlashCommandsPage from "@/features/acadenice/slash-commands-admin/pages/slash-commands-page";
export default function App() {
const { t } = useTranslation();
@@ -133,6 +135,8 @@ export default function App() {
path={"users/:userId/roles"}
element={}
/>
+ {/* Acadenice R3.3 — custom slash commands admin */}
+ } />
{!isCloud() && } />}
{isCloud() && } />}
diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx
index 1f89b745..b9d9bf1a 100644
--- a/apps/client/src/components/settings/settings-sidebar.tsx
+++ b/apps/client/src/components/settings/settings-sidebar.tsx
@@ -16,6 +16,7 @@ import {
IconHistory,
IconShieldCheck,
IconShieldLock,
+ IconSlash,
} from "@tabler/icons-react";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { Link, useLocation } from "react-router-dom";
@@ -108,6 +109,13 @@ const groupedData: DataGroup[] = [
path: "/settings/roles",
acadeniceCanManageRoles: true,
},
+ {
+ // Acadenice R3.3 — custom slash commands admin
+ label: "Slash commands",
+ icon: IconSlash,
+ path: "/settings/slash-commands",
+ acadeniceCanManageRoles: true,
+ },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx
new file mode 100644
index 00000000..04c52cfe
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-command-list.test.tsx
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import React from "react";
+import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils";
+import { SlashCommandList } from "../components/slash-command-list";
+import * as queries from "../queries/slash-commands-query";
+import * as rbacHook from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
+
+vi.mock("../queries/slash-commands-query", () => ({
+ useSlashCommandsQuery: vi.fn(),
+ useDeleteSlashCommandMutation: vi.fn(),
+ useToggleSlashCommandMutation: vi.fn(),
+ useCreateSlashCommandMutation: vi.fn(),
+ useUpdateSlashCommandMutation: vi.fn(),
+}));
+
+vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({
+ useAcadenicePermissions: vi.fn(),
+}));
+
+const mockPermissions = {
+ permissions: ["slash_commands:manage"],
+ hasPermission: (p: string) => p === "slash_commands:manage" || p === "admin:*",
+ canManageRoles: true,
+ isLoading: false,
+};
+
+const sampleCmd = {
+ id: "cmd-1",
+ workspaceId: "ws1",
+ keyword: "meeting-note",
+ label: "Meeting Note",
+ description: null,
+ icon: null,
+ actionType: "insert-template",
+ actionConfig: { template: "# Meeting\n\n" },
+ isEnabled: true,
+ createdBy: "u1",
+ createdAt: "2026-05-08T00:00:00Z",
+ updatedAt: "2026-05-08T00:00:00Z",
+};
+
+const noopMutation = {
+ mutate: vi.fn(),
+ isPending: false,
+ variables: undefined,
+};
+
+function setup(overrides = {}) {
+ (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue(mockPermissions);
+ (queries.useDeleteSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useToggleSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useCreateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useUpdateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useSlashCommandsQuery as ReturnType).mockReturnValue({
+ data: [sampleCmd],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ ...overrides,
+ });
+}
+
+describe("SlashCommandList", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the table with a command row", () => {
+ setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("/meeting-note")).toBeDefined();
+ expect(screen.getByText("Meeting Note")).toBeDefined();
+ });
+
+ it("shows loader while loading", () => {
+ setup({ isLoading: true, data: undefined });
+ render(
+
+
+ ,
+ );
+ // Mantine Loader renders an SVG role="presentation" or an aria-busy element
+ const loader = document.querySelector("[data-testid], svg, [aria-busy]");
+ expect(loader).toBeTruthy();
+ });
+
+ it("shows error alert when query fails", () => {
+ setup({ isLoading: false, data: undefined, error: new Error("fail") });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/error|fail/i)).toBeDefined();
+ });
+
+ it("shows empty state when no commands", () => {
+ setup({ data: [] });
+ render(
+
+
+ ,
+ );
+ // Empty state text should appear
+ const cell = document.querySelector("td");
+ expect(cell).toBeTruthy();
+ });
+
+ it("shows create button", () => {
+ setup();
+ render(
+
+
+ ,
+ );
+ const btn = screen.getByRole("button", { name: /create|new|add/i });
+ expect(btn).toBeDefined();
+ });
+});
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx
new file mode 100644
index 00000000..3053ae26
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/__tests__/slash-commands-page.test.tsx
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils";
+import SlashCommandsPage from "../pages/slash-commands-page";
+import * as rbacHook from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
+import * as queries from "../queries/slash-commands-query";
+
+vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({
+ useAcadenicePermissions: vi.fn(),
+}));
+
+vi.mock("../queries/slash-commands-query", () => ({
+ useSlashCommandsQuery: vi.fn(),
+ useDeleteSlashCommandMutation: vi.fn(),
+ useToggleSlashCommandMutation: vi.fn(),
+ useCreateSlashCommandMutation: vi.fn(),
+ useUpdateSlashCommandMutation: vi.fn(),
+}));
+
+vi.mock("@/lib/config.ts", () => ({
+ getAppName: () => "DocAdenice",
+ isCloud: () => false,
+}));
+
+const noopMutation = { mutate: vi.fn(), isPending: false, variables: undefined };
+
+function setupMutations() {
+ (queries.useDeleteSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useToggleSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useCreateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useUpdateSlashCommandMutation as ReturnType).mockReturnValue(noopMutation);
+ (queries.useSlashCommandsQuery as ReturnType).mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ });
+}
+
+describe("SlashCommandsPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMutations();
+ });
+
+ it("renders the list when user has slash_commands:manage", () => {
+ (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({
+ permissions: ["slash_commands:manage"],
+ hasPermission: (p: string) => p === "slash_commands:manage",
+ canManageRoles: true,
+ isLoading: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ // The table should be rendered (even empty)
+ expect(document.querySelector("[data-testid='slash-commands-table']")).toBeTruthy();
+ });
+
+ it("shows access denied when user lacks permission", () => {
+ (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({
+ permissions: [],
+ hasPermission: () => false,
+ canManageRoles: false,
+ isLoading: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/access denied|permission/i)).toBeDefined();
+ });
+
+ it("renders page title in document", () => {
+ (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({
+ permissions: ["admin:*"],
+ hasPermission: () => true,
+ canManageRoles: true,
+ isLoading: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ // SettingsTitle renders an h1 or heading
+ const heading = document.querySelector("h1, h2, [role='heading']");
+ expect(heading).toBeTruthy();
+ });
+});
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx
new file mode 100644
index 00000000..26f4bf68
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx
@@ -0,0 +1,334 @@
+import React, { useEffect } from "react";
+import {
+ Modal,
+ TextInput,
+ Textarea,
+ Select,
+ NumberInput,
+ Button,
+ Stack,
+ Group,
+ Divider,
+ Switch,
+ Text,
+ Alert,
+ Code,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { useTranslation } from "react-i18next";
+import { SlashCommandDto, CreateSlashCommandPayload } from "../services/slash-commands-client";
+
+const ACTION_TYPE_OPTIONS = [
+ { value: "insert-template", label: "Insert Template" },
+ { value: "insert-table", label: "Insert Table" },
+ { value: "embed-url", label: "Embed URL" },
+ { value: "run-webhook", label: "Run Webhook" },
+ { value: "insert-snippet", label: "Insert Code Snippet" },
+];
+
+interface Props {
+ opened: boolean;
+ onClose: () => void;
+ onSubmit: (payload: CreateSlashCommandPayload) => void;
+ initialValues?: SlashCommandDto | null;
+ isLoading?: boolean;
+}
+
+interface FormValues {
+ keyword: string;
+ label: string;
+ description: string;
+ icon: string;
+ actionType: string;
+ isEnabled: boolean;
+ // insert-template
+ template: string;
+ // insert-table
+ rows: number;
+ cols: number;
+ withHeaderRow: boolean;
+ // embed-url
+ url: string;
+ // run-webhook
+ webhookUrl: string;
+ webhookHeaders: string;
+ // insert-snippet
+ language: string;
+ code: string;
+}
+
+/**
+ * Polymorphic create/edit form for custom slash commands.
+ *
+ * The action_config section renders different fields depending on the selected
+ * actionType (discriminated union pattern). Validation is done client-side
+ * before submission; backend validates again with Zod.
+ */
+export function SlashCommandForm({
+ opened,
+ onClose,
+ onSubmit,
+ initialValues,
+ isLoading,
+}: Props) {
+ const { t } = useTranslation();
+
+ const form = useForm({
+ initialValues: {
+ keyword: "",
+ label: "",
+ description: "",
+ icon: "",
+ actionType: "insert-template",
+ isEnabled: true,
+ template: "# Title\n\n",
+ rows: 3,
+ cols: 3,
+ withHeaderRow: true,
+ url: "",
+ webhookUrl: "",
+ webhookHeaders: "",
+ language: "typescript",
+ code: "",
+ },
+ validate: {
+ keyword: (v) =>
+ /^[a-z0-9-]+$/.test(v)
+ ? null
+ : t("slash_commands.keyword_format_error"),
+ label: (v) => (v.trim().length > 0 ? null : t("slash_commands.label_required")),
+ url: (v, values) =>
+ values.actionType === "embed-url" && !v.startsWith("http")
+ ? t("slash_commands.url_required")
+ : null,
+ webhookUrl: (v, values) =>
+ values.actionType === "run-webhook" && !v.startsWith("https://")
+ ? t("slash_commands.webhook_https_required")
+ : null,
+ language: (v, values) =>
+ values.actionType === "insert-snippet" && !v.trim()
+ ? t("slash_commands.language_required")
+ : null,
+ },
+ });
+
+ // Populate form when editing an existing command
+ useEffect(() => {
+ if (!initialValues) {
+ form.reset();
+ return;
+ }
+ const cfg = initialValues.actionConfig as Record;
+ form.setValues({
+ keyword: initialValues.keyword,
+ label: initialValues.label,
+ description: initialValues.description ?? "",
+ icon: initialValues.icon ?? "",
+ actionType: initialValues.actionType,
+ isEnabled: initialValues.isEnabled,
+ template:
+ typeof cfg["template"] === "string"
+ ? cfg["template"]
+ : JSON.stringify(cfg["template"], null, 2),
+ rows: (cfg["rows"] as number) ?? 3,
+ cols: (cfg["cols"] as number) ?? 3,
+ withHeaderRow: (cfg["withHeaderRow"] as boolean) ?? true,
+ url: (cfg["url"] as string) ?? "",
+ webhookUrl: (cfg["webhookUrl"] as string) ?? "",
+ webhookHeaders: cfg["headers"]
+ ? JSON.stringify(cfg["headers"], null, 2)
+ : "",
+ language: (cfg["language"] as string) ?? "typescript",
+ code: (cfg["code"] as string) ?? "",
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialValues?.id]);
+
+ function buildActionConfig(values: FormValues): Record {
+ switch (values.actionType) {
+ case "insert-template":
+ return { template: values.template };
+ case "insert-table":
+ return {
+ rows: values.rows,
+ cols: values.cols,
+ withHeaderRow: values.withHeaderRow,
+ };
+ case "embed-url":
+ return { url: values.url };
+ case "run-webhook": {
+ let headers: Record | undefined;
+ if (values.webhookHeaders.trim()) {
+ try {
+ headers = JSON.parse(values.webhookHeaders) as Record;
+ } catch {
+ // Headers JSON is invalid — backend Zod will reject cleanly
+ }
+ }
+ return { webhookUrl: values.webhookUrl, ...(headers ? { headers } : {}) };
+ }
+ case "insert-snippet":
+ return { language: values.language, code: values.code };
+ default:
+ return {};
+ }
+ }
+
+ function handleSubmit(values: FormValues) {
+ onSubmit({
+ keyword: values.keyword,
+ label: values.label,
+ description: values.description || undefined,
+ icon: values.icon || undefined,
+ actionType: values.actionType as CreateSlashCommandPayload["actionType"],
+ actionConfig: buildActionConfig(values),
+ isEnabled: values.isEnabled,
+ });
+ }
+
+ const actionType = form.values.actionType;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-list.tsx b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-list.tsx
new file mode 100644
index 00000000..80f9ff73
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-list.tsx
@@ -0,0 +1,194 @@
+import React, { useState } from "react";
+import {
+ Table,
+ Badge,
+ Switch,
+ ActionIcon,
+ Group,
+ Text,
+ Loader,
+ Alert,
+ Button,
+ Tooltip,
+ Code,
+} from "@mantine/core";
+import { IconEdit, IconTrash, IconPlus } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { SlashCommandDto } from "../services/slash-commands-client";
+import {
+ useSlashCommandsQuery,
+ useDeleteSlashCommandMutation,
+ useToggleSlashCommandMutation,
+} from "../queries/slash-commands-query";
+import { SlashCommandForm } from "./slash-command-form";
+import {
+ useCreateSlashCommandMutation,
+ useUpdateSlashCommandMutation,
+} from "../queries/slash-commands-query";
+import { CreateSlashCommandPayload } from "../services/slash-commands-client";
+
+const ACTION_TYPE_COLOR: Record = {
+ "insert-template": "teal",
+ "insert-table": "blue",
+ "embed-url": "violet",
+ "run-webhook": "orange",
+ "insert-snippet": "gray",
+};
+
+/**
+ * Admin list of workspace custom slash commands.
+ *
+ * Displays all commands (enabled + disabled) with toggle, edit, delete.
+ * Uses optimistic update for the toggle switch.
+ */
+export function SlashCommandList() {
+ const { t } = useTranslation();
+ const { data: commands, isLoading, error, refetch } = useSlashCommandsQuery();
+ const deleteMutation = useDeleteSlashCommandMutation();
+ const toggleMutation = useToggleSlashCommandMutation();
+ const createMutation = useCreateSlashCommandMutation();
+ const updateMutation = useUpdateSlashCommandMutation();
+
+ const [formOpen, setFormOpen] = useState(false);
+ const [editTarget, setEditTarget] = useState(null);
+
+ function openCreate() {
+ setEditTarget(null);
+ setFormOpen(true);
+ }
+
+ function openEdit(cmd: SlashCommandDto) {
+ setEditTarget(cmd);
+ setFormOpen(true);
+ }
+
+ function handleFormSubmit(payload: CreateSlashCommandPayload) {
+ if (editTarget) {
+ updateMutation.mutate(
+ { id: editTarget.id, payload },
+ { onSuccess: () => setFormOpen(false) },
+ );
+ } else {
+ createMutation.mutate(payload, { onSuccess: () => setFormOpen(false) });
+ }
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ const rows =
+ commands && commands.length > 0 ? (
+ commands.map((cmd) => (
+
+
+ /{cmd.keyword}
+
+ {cmd.label}
+
+
+ {cmd.actionType}
+
+
+
+
+
+ toggleMutation.mutate({ id: cmd.id, isEnabled: e.currentTarget.checked })
+ }
+ aria-label={
+ cmd.isEnabled
+ ? t("slash_commands.disable_tooltip")
+ : t("slash_commands.enable_tooltip")
+ }
+ />
+
+
+
+
+
+ openEdit(cmd)}
+ aria-label={t("Edit")}
+ >
+
+
+
+
+ {
+ if (confirm(t("slash_commands.delete_confirm", { label: cmd.label }))) {
+ deleteMutation.mutate(cmd.id);
+ }
+ }}
+ aria-label={t("Delete")}
+ loading={deleteMutation.isPending && deleteMutation.variables === cmd.id}
+ >
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+
+ {t("slash_commands.empty_state")}
+
+
+
+ );
+
+ return (
+ <>
+
+ } onClick={openCreate}>
+ {t("slash_commands.create_button")}
+
+
+
+
+
+
+ {t("slash_commands.col_keyword")}
+ {t("slash_commands.col_label")}
+ {t("slash_commands.col_action_type")}
+ {t("slash_commands.col_enabled")}
+ {t("Actions")}
+
+
+ {rows}
+
+
+ setFormOpen(false)}
+ onSubmit={handleFormSubmit}
+ initialValues={editTarget}
+ isLoading={createMutation.isPending || updateMutation.isPending}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/pages/slash-commands-page.tsx b/apps/client/src/features/acadenice/slash-commands-admin/pages/slash-commands-page.tsx
new file mode 100644
index 00000000..10cadb35
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/pages/slash-commands-page.tsx
@@ -0,0 +1,40 @@
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import { Alert } from "@mantine/core";
+import SettingsTitle from "@/components/settings/settings-title.tsx";
+import { getAppName } from "@/lib/config.ts";
+import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
+import { SlashCommandList } from "../components/slash-command-list";
+
+/**
+ * Settings page at /settings/slash-commands.
+ *
+ * Visible only to users with `slash_commands:manage` permission.
+ * Renders the SlashCommandList admin UI.
+ */
+export default function SlashCommandsPage() {
+ const { t } = useTranslation();
+ const { canManageRoles, hasPermission } = useAcadenicePermissions();
+ const canManageSlashCommands =
+ hasPermission("slash_commands:manage") || hasPermission("admin:*");
+
+ return (
+ <>
+
+
+ {t("slash_commands.page_title")} - {getAppName()}
+
+
+
+
+
+ {!canManageSlashCommands && (
+
+ {t("slash_commands.access_denied_description")}
+
+ )}
+
+ {canManageSlashCommands && }
+ >
+ );
+}
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/queries/slash-commands-query.ts b/apps/client/src/features/acadenice/slash-commands-admin/queries/slash-commands-query.ts
new file mode 100644
index 00000000..1bb010de
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/queries/slash-commands-query.ts
@@ -0,0 +1,125 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import {
+ slashCommandsClient,
+ SlashCommandDto,
+ CreateSlashCommandPayload,
+ UpdateSlashCommandPayload,
+} from "../services/slash-commands-client";
+
+export const SLASH_COMMANDS_QUERY_KEY = ["acadenice", "slash-commands"] as const;
+
+export function useSlashCommandsQuery(): UseQueryResult {
+ return useQuery({
+ queryKey: SLASH_COMMANDS_QUERY_KEY,
+ queryFn: () => slashCommandsClient.list(),
+ staleTime: 60 * 1000,
+ });
+}
+
+function extractApiError(error: unknown): string {
+ const e = error as { response?: { data?: { message?: string | string[] } } };
+ const msg = e?.response?.data?.message;
+ if (!msg) return "An unexpected error occurred";
+ return Array.isArray(msg) ? msg.join(", ") : msg;
+}
+
+export function useCreateSlashCommandMutation() {
+ const qc = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (payload: CreateSlashCommandPayload) =>
+ slashCommandsClient.create(payload),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: SLASH_COMMANDS_QUERY_KEY });
+ notifications.show({
+ color: "green",
+ message: t("slash_commands.create_success"),
+ });
+ },
+ onError: (err) => {
+ notifications.show({
+ color: "red",
+ message: extractApiError(err),
+ });
+ },
+ });
+}
+
+export function useUpdateSlashCommandMutation() {
+ const qc = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: ({ id, payload }: { id: string; payload: UpdateSlashCommandPayload }) =>
+ slashCommandsClient.update(id, payload),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: SLASH_COMMANDS_QUERY_KEY });
+ notifications.show({
+ color: "green",
+ message: t("slash_commands.update_success"),
+ });
+ },
+ onError: (err) => {
+ notifications.show({
+ color: "red",
+ message: extractApiError(err),
+ });
+ },
+ });
+}
+
+export function useDeleteSlashCommandMutation() {
+ const qc = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (id: string) => slashCommandsClient.delete(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: SLASH_COMMANDS_QUERY_KEY });
+ notifications.show({
+ color: "green",
+ message: t("slash_commands.delete_success"),
+ });
+ },
+ onError: (err) => {
+ notifications.show({
+ color: "red",
+ message: extractApiError(err),
+ });
+ },
+ });
+}
+
+export function useToggleSlashCommandMutation() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, isEnabled }: { id: string; isEnabled: boolean }) =>
+ slashCommandsClient.toggle(id, isEnabled),
+ // Optimistic update — flip isEnabled immediately then reconcile on settle.
+ onMutate: async ({ id, isEnabled }) => {
+ await qc.cancelQueries({ queryKey: SLASH_COMMANDS_QUERY_KEY });
+ const prev = qc.getQueryData(SLASH_COMMANDS_QUERY_KEY);
+ qc.setQueryData(SLASH_COMMANDS_QUERY_KEY, (old) =>
+ old?.map((cmd) => (cmd.id === id ? { ...cmd, isEnabled } : cmd)) ?? [],
+ );
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev) {
+ qc.setQueryData(SLASH_COMMANDS_QUERY_KEY, ctx.prev);
+ }
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: SLASH_COMMANDS_QUERY_KEY });
+ },
+ });
+}
diff --git a/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts b/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts
new file mode 100644
index 00000000..ef0cce2b
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts
@@ -0,0 +1,68 @@
+import axios from 'axios';
+
+// Re-use the same axios instance that Docmost uses for authenticated requests.
+// The `withCredentials` is handled globally by the Docmost axios setup.
+
+export interface SlashCommandDto {
+ id: string;
+ workspaceId: string;
+ keyword: string;
+ label: string;
+ description: string | null;
+ icon: string | null;
+ actionType:
+ | 'insert-template'
+ | 'insert-table'
+ | 'embed-url'
+ | 'run-webhook'
+ | 'insert-snippet';
+ actionConfig: Record;
+ isEnabled: boolean;
+ createdBy: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateSlashCommandPayload {
+ keyword: string;
+ label: string;
+ description?: string;
+ icon?: string;
+ actionType: SlashCommandDto['actionType'];
+ actionConfig: Record;
+ isEnabled?: boolean;
+}
+
+export type UpdateSlashCommandPayload = Partial;
+
+const BASE = '/api/acadenice/slash-commands';
+
+export const slashCommandsClient = {
+ list(): Promise {
+ return axios.get(BASE).then((r) => r.data);
+ },
+
+ get(id: string): Promise {
+ return axios.get(`${BASE}/${id}`).then((r) => r.data);
+ },
+
+ create(payload: CreateSlashCommandPayload): Promise {
+ return axios.post(BASE, payload).then((r) => r.data);
+ },
+
+ update(id: string, payload: UpdateSlashCommandPayload): Promise {
+ return axios
+ .patch(`${BASE}/${id}`, payload)
+ .then((r) => r.data);
+ },
+
+ delete(id: string): Promise {
+ return axios.delete(`${BASE}/${id}`).then(() => undefined);
+ },
+
+ toggle(id: string, isEnabled: boolean): Promise {
+ return axios
+ .patch(`${BASE}/${id}`, { isEnabled })
+ .then((r) => r.data);
+ },
+};
diff --git a/apps/client/src/features/acadenice/slash-commands/__tests__/buildCustomSlashItems.test.ts b/apps/client/src/features/acadenice/slash-commands/__tests__/buildCustomSlashItems.test.ts
new file mode 100644
index 00000000..d736a83b
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands/__tests__/buildCustomSlashItems.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from "vitest";
+import { buildCustomSlashItems } from "../executor/buildCustomSlashItems";
+import type { SlashCommandDto } from "../../slash-commands-admin/services/slash-commands-client";
+
+const base: SlashCommandDto = {
+ id: "c1",
+ workspaceId: "ws1",
+ keyword: "meeting-note",
+ label: "Meeting Note",
+ description: "Insert a meeting note",
+ icon: null,
+ actionType: "insert-template",
+ actionConfig: { template: "# Meeting\n\n" },
+ isEnabled: true,
+ createdBy: "u1",
+ createdAt: "2026-05-08T00:00:00Z",
+ updatedAt: "2026-05-08T00:00:00Z",
+};
+
+describe("buildCustomSlashItems", () => {
+ it("converts enabled commands to slash menu items", () => {
+ const items = buildCustomSlashItems([base]);
+ expect(items).toHaveLength(1);
+ expect(items[0].title).toBe("Meeting Note");
+ expect(items[0].searchTerms).toContain("meeting-note");
+ });
+
+ it("filters out disabled commands", () => {
+ const disabled: SlashCommandDto = { ...base, isEnabled: false };
+ const items = buildCustomSlashItems([disabled]);
+ expect(items).toHaveLength(0);
+ });
+
+ it("includes fallback description when description is null", () => {
+ const noDesc: SlashCommandDto = { ...base, description: null };
+ const items = buildCustomSlashItems([noDesc]);
+ expect(items[0].description).toContain("meeting-note");
+ });
+
+ it("each item has a command function", () => {
+ const items = buildCustomSlashItems([base]);
+ expect(typeof items[0].command).toBe("function");
+ });
+
+ it("returns empty array for empty input", () => {
+ const items = buildCustomSlashItems([]);
+ expect(items).toHaveLength(0);
+ });
+
+ it("keyword is included in searchTerms", () => {
+ const items = buildCustomSlashItems([base]);
+ expect(items[0].searchTerms).toContain("meeting-note");
+ });
+
+ it("multiple commands are all converted", () => {
+ const cmds: SlashCommandDto[] = [
+ base,
+ { ...base, id: "c2", keyword: "todo-block", label: "Todo Block", isEnabled: true },
+ ];
+ const items = buildCustomSlashItems(cmds);
+ expect(items).toHaveLength(2);
+ expect(items.map((i) => i.title)).toContain("Todo Block");
+ });
+});
diff --git a/apps/client/src/features/acadenice/slash-commands/__tests__/use-custom-slash-commands.test.ts b/apps/client/src/features/acadenice/slash-commands/__tests__/use-custom-slash-commands.test.ts
new file mode 100644
index 00000000..a333f7e4
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands/__tests__/use-custom-slash-commands.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+import { useCustomSlashCommands } from "../hooks/use-custom-slash-commands";
+import { slashCommandsClient } from "../../slash-commands-admin/services/slash-commands-client";
+
+vi.mock("../../slash-commands-admin/services/slash-commands-client", () => ({
+ slashCommandsClient: {
+ list: vi.fn(),
+ },
+}));
+
+const mockList = slashCommandsClient.list as ReturnType;
+
+const sampleCommands = [
+ {
+ id: "c1",
+ workspaceId: "ws1",
+ keyword: "meeting-note",
+ label: "Meeting Note",
+ description: null,
+ icon: null,
+ actionType: "insert-template" as const,
+ actionConfig: { template: "# Meeting\n\n" },
+ isEnabled: true,
+ createdBy: "u1",
+ createdAt: "2026-05-08T00:00:00Z",
+ updatedAt: "2026-05-08T00:00:00Z",
+ },
+];
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
+ });
+ return React.createElement(QueryClientProvider, { client: qc }, children);
+}
+
+describe("useCustomSlashCommands", () => {
+ beforeEach(() => {
+ mockList.mockReset();
+ });
+
+ it("returns commands when API succeeds", async () => {
+ mockList.mockResolvedValueOnce(sampleCommands);
+
+ const { result } = renderHook(() => useCustomSlashCommands(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toHaveLength(1);
+ expect(result.current.data![0].keyword).toBe("meeting-note");
+ });
+
+ it("returns empty array state when API returns empty", async () => {
+ mockList.mockResolvedValueOnce([]);
+
+ const { result } = renderHook(() => useCustomSlashCommands(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toHaveLength(0);
+ });
+
+ it("enters error state on API failure (does not throw)", async () => {
+ mockList.mockRejectedValueOnce(new Error("Network error"));
+
+ const { result } = renderHook(() => useCustomSlashCommands(), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error?.message).toBe("Network error");
+ });
+
+ it("calls slashCommandsClient.list on mount", async () => {
+ mockList.mockResolvedValueOnce([]);
+ renderHook(() => useCustomSlashCommands(), { wrapper });
+
+ await waitFor(() => expect(mockList).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/apps/client/src/features/acadenice/slash-commands/executor/actionExecutor.ts b/apps/client/src/features/acadenice/slash-commands/executor/actionExecutor.ts
new file mode 100644
index 00000000..8f82dd93
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands/executor/actionExecutor.ts
@@ -0,0 +1,182 @@
+import { Editor } from "@tiptap/react";
+import type { SlashCommandDto } from "../../slash-commands-admin/services/slash-commands-client";
+
+// Max wait for a webhook response before showing timeout error.
+const WEBHOOK_TIMEOUT_MS = 10_000;
+
+export interface ExecutionResult {
+ success: boolean;
+ message?: string;
+}
+
+/**
+ * Dispatches the appropriate editor action for a custom slash command.
+ *
+ * Each action_type maps to a different editor command or async side-effect.
+ * Called by the menu item descriptor's `command` callback.
+ */
+export async function executeAction(
+ editor: Editor,
+ cmd: SlashCommandDto,
+ range: { from: number; to: number },
+): Promise {
+ // Delete the slash trigger text before inserting content
+ editor.chain().focus().deleteRange(range).run();
+
+ const cfg = cmd.actionConfig as Record;
+
+ switch (cmd.actionType) {
+ case "insert-template":
+ return executeInsertTemplate(editor, cfg);
+
+ case "insert-table":
+ return executeInsertTable(editor, cfg);
+
+ case "embed-url":
+ return executeEmbedUrl(editor, cfg);
+
+ case "insert-snippet":
+ return executeInsertSnippet(editor, cfg);
+
+ case "run-webhook":
+ return executeWebhook(editor, cfg);
+
+ default:
+ return { success: false, message: `Unknown action type: ${cmd.actionType}` };
+ }
+}
+
+function executeInsertTemplate(
+ editor: Editor,
+ cfg: Record,
+): ExecutionResult {
+ const template = cfg["template"];
+ if (!template) return { success: false, message: "Template is empty" };
+
+ if (typeof template === "string") {
+ // Markdown string — insert as plain paragraph content
+ editor.chain().focus().insertContent(template).run();
+ } else {
+ // Tiptap JSON document fragment
+ editor
+ .chain()
+ .focus()
+ .insertContent(template as Record)
+ .run();
+ }
+ return { success: true };
+}
+
+function executeInsertTable(
+ editor: Editor,
+ cfg: Record,
+): ExecutionResult {
+ const rows = (cfg["rows"] as number) ?? 3;
+ const cols = (cfg["cols"] as number) ?? 3;
+ const withHeaderRow = (cfg["withHeaderRow"] as boolean) ?? true;
+
+ editor
+ .chain()
+ .focus()
+ .insertTable({ rows, cols, withHeaderRow })
+ .run();
+ return { success: true };
+}
+
+function executeEmbedUrl(
+ editor: Editor,
+ cfg: Record,
+): ExecutionResult {
+ const url = cfg["url"] as string;
+ if (!url) return { success: false, message: "No URL configured" };
+
+ // Reuse Docmost's built-in setEmbed command (supports iframes, external providers)
+ editor.chain().focus().setEmbed({ provider: "iframe", src: url }).run();
+ return { success: true };
+}
+
+function executeInsertSnippet(
+ editor: Editor,
+ cfg: Record,
+): ExecutionResult {
+ const language = (cfg["language"] as string) ?? "text";
+ const code = (cfg["code"] as string) ?? "";
+
+ editor
+ .chain()
+ .focus()
+ .setCodeBlock({ language })
+ .insertContent(code)
+ .run();
+ return { success: true };
+}
+
+async function executeWebhook(
+ _editor: Editor,
+ cfg: Record,
+): Promise {
+ const webhookUrl = cfg["webhookUrl"] as string;
+ if (!webhookUrl) return { success: false, message: "No webhook URL configured" };
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...((cfg["headers"] as Record) ?? {}),
+ };
+
+ // Build context payload — page + user info extracted from document meta
+ const context = {
+ source: "docadenice-slash-command",
+ timestamp: new Date().toISOString(),
+ page: {
+ url: window.location.href,
+ title: document.title,
+ },
+ };
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
+
+ try {
+ const res = await fetch(webhookUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(context),
+ signal: controller.signal,
+ // Security: do not follow redirects automatically (prevents open-redirect abuse)
+ redirect: "error",
+ });
+
+ clearTimeout(timer);
+
+ if (!res.ok) {
+ return {
+ success: false,
+ message: `Webhook returned ${res.status}: ${res.statusText}`,
+ };
+ }
+
+ // Read the response body but cap at 1 MB to avoid memory exhaustion
+ const blob = await res.blob();
+ if (blob.size > 1_000_000) {
+ return { success: true, message: "Webhook executed (response truncated)" };
+ }
+
+ const text = await blob.text();
+ let resultMessage = "Webhook executed";
+ try {
+ const json = JSON.parse(text) as { message?: string };
+ if (json.message) resultMessage = json.message;
+ } catch {
+ // Not JSON — use the raw text if short enough for display
+ if (text.length <= 200) resultMessage = text;
+ }
+
+ return { success: true, message: resultMessage };
+ } catch (err) {
+ clearTimeout(timer);
+ if ((err as Error).name === "AbortError") {
+ return { success: false, message: "Webhook timed out after 10s" };
+ }
+ return { success: false, message: (err as Error).message };
+ }
+}
diff --git a/apps/client/src/features/acadenice/slash-commands/executor/buildCustomSlashItems.tsx b/apps/client/src/features/acadenice/slash-commands/executor/buildCustomSlashItems.tsx
new file mode 100644
index 00000000..bb806930
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands/executor/buildCustomSlashItems.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import type { Editor } from "@tiptap/core";
+import { Range } from "@tiptap/core";
+import { IconCommand } from "@tabler/icons-react";
+import { notifications } from "@mantine/notifications";
+import type { SlashCommandDto } from "../../slash-commands-admin/services/slash-commands-client";
+import { executeAction } from "./actionExecutor";
+import type { SlashMenuItemType } from "@/features/editor/components/slash-menu/types";
+
+/**
+ * Converts a list of server-side SlashCommandDto objects into the Tiptap slash
+ * menu item descriptors that `getSuggestionItems` expects.
+ *
+ * Each custom command is converted to a `SlashMenuItemType` with:
+ * - keyword mapped to `searchTerms` so the menu fuzzy-filter picks it up
+ * - the server-side action_type dispatched via `executeAction`
+ * - a fallback icon (IconCommand) used when `cmd.icon` is not a Tabler icon name
+ *
+ * Webhook commands show a success/error notification after execution.
+ */
+export function buildCustomSlashItems(
+ commands: SlashCommandDto[],
+): SlashMenuItemType[] {
+ return commands
+ .filter((cmd) => cmd.isEnabled)
+ .map((cmd) => ({
+ title: cmd.label,
+ description: cmd.description ?? `Custom command: /${cmd.keyword}`,
+ searchTerms: [cmd.keyword, cmd.label.toLowerCase(), "custom"],
+ // Icon: try to resolve from Tabler icons registry; fall back to IconCommand.
+ // Dynamic import of the icon by string name is intentionally avoided to keep
+ // the bundle predictable. Admins should document the supported icon names.
+ icon: IconCommand,
+ command: ({ editor, range }: { editor: Editor; range: Range }) => {
+ // executeAction is async for webhook; we fire-and-forget and show notification.
+ executeAction(editor, cmd, range)
+ .then((result) => {
+ if (cmd.actionType === "run-webhook") {
+ notifications.show({
+ color: result.success ? "green" : "red",
+ title: cmd.label,
+ message: result.message ?? (result.success ? "Done" : "Failed"),
+ });
+ }
+ })
+ .catch((err: Error) => {
+ notifications.show({
+ color: "red",
+ title: cmd.label,
+ message: err.message,
+ });
+ });
+ },
+ }));
+}
diff --git a/apps/client/src/features/acadenice/slash-commands/hooks/use-custom-slash-commands.ts b/apps/client/src/features/acadenice/slash-commands/hooks/use-custom-slash-commands.ts
new file mode 100644
index 00000000..55c3784e
--- /dev/null
+++ b/apps/client/src/features/acadenice/slash-commands/hooks/use-custom-slash-commands.ts
@@ -0,0 +1,23 @@
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { slashCommandsClient, SlashCommandDto } from "../../slash-commands-admin/services/slash-commands-client";
+
+export const CUSTOM_SLASH_COMMANDS_QUERY_KEY = ["acadenice", "custom-slash-commands"] as const;
+
+/**
+ * Fetches active custom slash commands for the current workspace.
+ *
+ * The workspace context is derived server-side from the authenticated JWT —
+ * no need to pass workspaceId explicitly from the client.
+ *
+ * Stale after 2 minutes — acceptable lag for admin-declared commands.
+ * Returns empty list on error (graceful degradation: native commands still work).
+ */
+export function useCustomSlashCommands(): UseQueryResult {
+ return useQuery({
+ queryKey: CUSTOM_SLASH_COMMANDS_QUERY_KEY,
+ queryFn: () => slashCommandsClient.list(),
+ staleTime: 2 * 60 * 1000,
+ // Network errors must not break the slash menu; the query will retry
+ // silently per React Query default policy (3 attempts, exponential backoff).
+ });
+}
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 2b69aaed..d88a1dac 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -695,10 +695,28 @@ const CommandGroups: SlashMenuGroupedItemsType = {
export const getSuggestionItems = ({
query,
excludeItems,
+ // Acadenice R3.3 — custom commands declared by workspace admins,
+ // fetched at editor mount time via useCustomSlashCommands hook and
+ // passed down to the suggestion extension. Merged into the 'acadenice'
+ // group before filtering so they participate in the same fuzzy search.
+ customSlashItems,
}: {
query: string;
excludeItems?: Set;
+ customSlashItems?: SlashMenuItemType[];
}): SlashMenuGroupedItemsType => {
+ // Build a local CommandGroups copy that includes custom items in 'acadenice'.
+ // We do NOT mutate the module-level CommandGroups (shared across renders).
+ const groups: typeof CommandGroups = customSlashItems && customSlashItems.length > 0
+ ? {
+ ...CommandGroups,
+ acadenice: [
+ ...(CommandGroups.acadenice ?? []),
+ ...(customSlashItems as typeof CommandGroups.acadenice),
+ ],
+ }
+ : CommandGroups;
+
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
@@ -712,7 +730,7 @@ export const getSuggestionItems = ({
return false;
};
- for (const [group, items] of Object.entries(CommandGroups)) {
+ for (const [group, items] of Object.entries(groups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
return (
diff --git a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts
index 9542c4ef..dc06a637 100644
--- a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts
+++ b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts
@@ -50,6 +50,8 @@ export const PERMISSION_KEYS = [
// Meta
'roles:manage',
+ // Acadenice R3.3 — custom slash commands admin
+ 'slash_commands:manage',
'admin:*',
] as const;
@@ -173,6 +175,13 @@ export const PERMISSIONS_CATALOG: ReadonlyArray = [
description:
'Manage acadenice_role definitions and user-role assignments',
},
+ {
+ // R3.3 — custom slash commands admin
+ key: 'slash_commands:manage',
+ group: 'meta',
+ description:
+ 'Create, edit, delete and toggle workspace custom slash commands',
+ },
{
key: 'admin:*',
group: 'meta',
diff --git a/apps/server/src/core/acadenice/rbac/services/seed.service.ts b/apps/server/src/core/acadenice/rbac/services/seed.service.ts
index 614c08d5..250ce864 100644
--- a/apps/server/src/core/acadenice/rbac/services/seed.service.ts
+++ b/apps/server/src/core/acadenice/rbac/services/seed.service.ts
@@ -54,6 +54,8 @@ const SYSTEM_ROLES: ReadonlyArray = [
'attachments:upload',
'users:invite',
'users:write',
+ // R3.3 — slash command management
+ 'slash_commands:manage',
],
},
{
diff --git a/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts b/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts
new file mode 100644
index 00000000..6eb7158a
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts
@@ -0,0 +1,121 @@
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard';
+import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
+import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
+import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import { SlashCommandService } from '../services/slash-command.service';
+import {
+ CreateSlashCommandDto,
+ UpdateSlashCommandDto,
+ SlashCommandDto,
+ createSlashCommandSchema,
+ updateSlashCommandSchema,
+} from '../dto/slash-command.dto';
+import { ZodError } from 'zod';
+
+function parseBody(schema: { parse: (v: unknown) => T }, body: unknown): T {
+ try {
+ return schema.parse(body);
+ } catch (err) {
+ if (err instanceof ZodError) {
+ throw new BadRequestException({
+ message: 'Validation failed',
+ errors: err.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
+ });
+ }
+ throw err;
+ }
+}
+
+/**
+ * REST controller for workspace custom slash commands.
+ *
+ * Public read (GET list):
+ * Any authenticated workspace member can list active commands — the editor
+ * runtime hook calls this on mount to merge custom commands into the menu.
+ *
+ * Write (POST / PATCH / DELETE):
+ * Requires permission `slash_commands:manage` (workspace Owner + Admin by
+ * default via the seed — see permissions-catalog.ts).
+ */
+@UseGuards(JwtAuthGuard)
+@Controller('acadenice/slash-commands')
+export class SlashCommandsController {
+ constructor(private readonly slashCommandService: SlashCommandService) {}
+
+ /**
+ * Returns all active custom slash commands for the current workspace.
+ * Called by the editor runtime hook on every page open.
+ */
+ @Get()
+ async list(
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return this.slashCommandService.list(workspace.id);
+ }
+
+ /**
+ * Returns a single command by ID (admin UI detail view).
+ * Requires slash_commands:manage to avoid leaking webhook URLs to
+ * non-admin members who only need the runtime menu items.
+ */
+ @Get(':id')
+ @UseGuards(AcadenicePermissionsGuard)
+ @RequirePermission('slash_commands:manage')
+ async get(
+ @Param('id', ParseUUIDPipe) id: string,
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return this.slashCommandService.get(id, workspace.id);
+ }
+
+ @Post()
+ @UseGuards(AcadenicePermissionsGuard)
+ @RequirePermission('slash_commands:manage')
+ async create(
+ @Body() rawBody: unknown,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ const dto = parseBody(createSlashCommandSchema, rawBody);
+ return this.slashCommandService.create(workspace.id, user.id, dto);
+ }
+
+ @Patch(':id')
+ @UseGuards(AcadenicePermissionsGuard)
+ @RequirePermission('slash_commands:manage')
+ async update(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() rawBody: unknown,
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ const dto = parseBody(updateSlashCommandSchema, rawBody);
+ return this.slashCommandService.update(id, workspace.id, dto);
+ }
+
+ @Delete(':id')
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @UseGuards(AcadenicePermissionsGuard)
+ @RequirePermission('slash_commands:manage')
+ async delete(
+ @Param('id', ParseUUIDPipe) id: string,
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return this.slashCommandService.delete(id, workspace.id);
+ }
+}
diff --git a/apps/server/src/core/acadenice/slash-commands/dto/slash-command.dto.ts b/apps/server/src/core/acadenice/slash-commands/dto/slash-command.dto.ts
new file mode 100644
index 00000000..665c3d35
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/dto/slash-command.dto.ts
@@ -0,0 +1,101 @@
+import { z } from 'zod';
+
+// ---------------------------------------------------------------------------
+// Action config schemas — one per action_type (discriminated union).
+// Zod validates incoming payloads; NestJS DTOs use the inferred TS types.
+// ---------------------------------------------------------------------------
+
+const insertTemplateConfigSchema = z.object({
+ // Accepts either a Tiptap JSON doc (object) or raw Markdown string.
+ template: z.union([z.record(z.unknown()), z.string()]),
+});
+
+const insertTableConfigSchema = z.object({
+ rows: z.number().int().min(1).max(50).default(3),
+ cols: z.number().int().min(1).max(20).default(3),
+ withHeaderRow: z.boolean().default(true),
+});
+
+const embedUrlConfigSchema = z.object({
+ url: z.string().url('embed-url action requires a valid URL'),
+});
+
+const runWebhookConfigSchema = z.object({
+ webhookUrl: z.string().url('run-webhook action requires a valid URL'),
+ // Optional static headers injected into the POST (e.g. X-Token: xxx).
+ // Auth headers (Authorization) are intentionally omitted from the stored
+ // config; callers should use a secret-manager proxy instead.
+ headers: z.record(z.string()).optional(),
+});
+
+const insertSnippetConfigSchema = z.object({
+ language: z.string().min(1),
+ // Optional starter code; defaults to an empty block.
+ code: z.string().default(''),
+});
+
+// Discriminated union used by the action validator.
+export const actionConfigSchema = z.discriminatedUnion('_type', [
+ insertTemplateConfigSchema.extend({ _type: z.literal('insert-template') }),
+ insertTableConfigSchema.extend({ _type: z.literal('insert-table') }),
+ embedUrlConfigSchema.extend({ _type: z.literal('embed-url') }),
+ runWebhookConfigSchema.extend({ _type: z.literal('run-webhook') }),
+ insertSnippetConfigSchema.extend({ _type: z.literal('insert-snippet') }),
+]);
+
+export type ActionConfig = z.infer;
+
+// ---------------------------------------------------------------------------
+// Create DTO
+// ---------------------------------------------------------------------------
+
+export const createSlashCommandSchema = z.object({
+ keyword: z
+ .string()
+ .min(1)
+ .max(50)
+ .regex(/^[a-z0-9-]+$/, 'keyword must be lowercase alphanumeric with hyphens'),
+ label: z.string().min(1).max(100),
+ description: z.string().max(500).optional(),
+ icon: z.string().max(50).optional(),
+ actionType: z.enum([
+ 'insert-template',
+ 'insert-table',
+ 'embed-url',
+ 'run-webhook',
+ 'insert-snippet',
+ ]),
+ // action_config is validated separately by ActionValidatorService to produce
+ // clear per-field errors against the discriminated union.
+ actionConfig: z.record(z.unknown()),
+ isEnabled: z.boolean().default(true),
+});
+
+export type CreateSlashCommandDto = z.infer;
+
+// ---------------------------------------------------------------------------
+// Update DTO — every field is optional
+// ---------------------------------------------------------------------------
+
+export const updateSlashCommandSchema = createSlashCommandSchema.partial();
+
+export type UpdateSlashCommandDto = z.infer;
+
+// ---------------------------------------------------------------------------
+// Response DTO — what the API returns for each command
+// ---------------------------------------------------------------------------
+
+export interface SlashCommandDto {
+ id: string;
+ workspaceId: string;
+ keyword: string;
+ label: string;
+ description: string | null;
+ icon: string | null;
+ actionType: string;
+ actionConfig: Record;
+ isEnabled: boolean;
+ createdBy: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
diff --git a/apps/server/src/core/acadenice/slash-commands/services/action-validator.service.ts b/apps/server/src/core/acadenice/slash-commands/services/action-validator.service.ts
new file mode 100644
index 00000000..91f41a5e
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/services/action-validator.service.ts
@@ -0,0 +1,106 @@
+import { Injectable, BadRequestException } from '@nestjs/common';
+import { actionConfigSchema } from '../dto/slash-command.dto';
+import { ZodError } from 'zod';
+
+const ALLOWED_ACTION_TYPES = [
+ 'insert-template',
+ 'insert-table',
+ 'embed-url',
+ 'run-webhook',
+ 'insert-snippet',
+] as const;
+
+type ActionType = (typeof ALLOWED_ACTION_TYPES)[number];
+
+/**
+ * Validates action_config JSONB against the discriminated union schema.
+ *
+ * Each action_type has its own shape; passing the wrong config for a given
+ * type produces a structured 400 that the frontend can map to form errors.
+ *
+ * Webhook URL allowlist (optional):
+ * ACADENICE_WEBHOOK_ALLOWLIST env var = comma-separated hostname/URL prefixes.
+ * If unset, all URLs are accepted (with a WARN log).
+ * If set, the webhook URL must start with one of the listed prefixes or the
+ * request is rejected with 400.
+ */
+@Injectable()
+export class ActionValidatorService {
+ private readonly webhookAllowlist: string[] | null;
+
+ constructor() {
+ const raw = process.env.ACADENICE_WEBHOOK_ALLOWLIST;
+ this.webhookAllowlist =
+ raw && raw.trim().length > 0
+ ? raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : null;
+ }
+
+ /**
+ * Validates and returns the normalised action_config.
+ * Throws BadRequestException on validation failure.
+ */
+ validate(
+ actionType: string,
+ rawConfig: Record,
+ ): Record {
+ if (!ALLOWED_ACTION_TYPES.includes(actionType as ActionType)) {
+ throw new BadRequestException(
+ `Unknown action_type "${actionType}". Allowed: ${ALLOWED_ACTION_TYPES.join(', ')}`,
+ );
+ }
+
+ const payload = { ...rawConfig, _type: actionType };
+
+ let parsed: Record;
+ try {
+ parsed = actionConfigSchema.parse(payload) as Record;
+ } catch (err) {
+ if (err instanceof ZodError) {
+ throw new BadRequestException({
+ message: 'Invalid action_config for action_type ' + actionType,
+ errors: err.errors.map((e) => ({
+ path: e.path.join('.'),
+ message: e.message,
+ })),
+ });
+ }
+ throw err;
+ }
+
+ // Remove the discriminant _type before storing — it is redundant with
+ // action_type column and would inflate the stored JSONB.
+ const { _type: _, ...config } = parsed;
+
+ if (actionType === 'run-webhook') {
+ this.validateWebhookUrl(config['webhookUrl'] as string);
+ }
+
+ return config;
+ }
+
+ private validateWebhookUrl(url: string): void {
+ if (!this.webhookAllowlist) {
+ // No allowlist configured — accept any HTTPS URL but log for audit.
+ if (!url.startsWith('https://')) {
+ throw new BadRequestException(
+ 'run-webhook URL must use HTTPS to prevent plaintext credential leakage',
+ );
+ }
+ return;
+ }
+
+ const allowed = this.webhookAllowlist.some((prefix) =>
+ url.startsWith(prefix),
+ );
+ if (!allowed) {
+ throw new BadRequestException(
+ `run-webhook URL is not in the allowlist. ` +
+ `Set ACADENICE_WEBHOOK_ALLOWLIST env var to configure allowed URL prefixes.`,
+ );
+ }
+ }
+}
diff --git a/apps/server/src/core/acadenice/slash-commands/services/slash-command.service.ts b/apps/server/src/core/acadenice/slash-commands/services/slash-command.service.ts
new file mode 100644
index 00000000..f68f3b1f
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/services/slash-command.service.ts
@@ -0,0 +1,243 @@
+import {
+ Injectable,
+ Logger,
+ NotFoundException,
+ ConflictException,
+ ForbiddenException,
+} from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { sql } from 'kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
+import { CreateSlashCommandDto, UpdateSlashCommandDto, SlashCommandDto } from '../dto/slash-command.dto';
+import { ActionValidatorService } from './action-validator.service';
+
+/**
+ * CRUD service for workspace-scoped custom slash commands.
+ *
+ * All write operations require the caller to hold `slash_commands:manage`
+ * (enforced at controller level via AcadenicePermissionsGuard).
+ *
+ * The service operates directly on the `acadenice_slash_command` table via
+ * raw Kysely SQL to stay out of the typed schema owned by upstream Docmost.
+ */
+@Injectable()
+export class SlashCommandService {
+ private readonly logger = new Logger(SlashCommandService.name);
+
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly actionValidator: ActionValidatorService,
+ ) {}
+
+ /**
+ * Returns all enabled commands for the workspace — called by the editor
+ * runtime hook (unauthenticated read in read-only spaces is prevented by
+ * the workspace-level JWT guard upstream).
+ */
+ async list(workspaceId: string, includeDisabled = false): Promise {
+ const rows = await sql`
+ SELECT
+ id,
+ workspace_id AS "workspaceId",
+ keyword,
+ label,
+ description,
+ icon,
+ action_type AS "actionType",
+ action_config AS "actionConfig",
+ is_enabled AS "isEnabled",
+ created_by AS "createdBy",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ FROM acadenice_slash_command
+ WHERE workspace_id = ${workspaceId}
+ ${!includeDisabled ? sql`AND is_enabled = true` : sql``}
+ ORDER BY label ASC
+ `.execute(this.db);
+
+ return rows.rows;
+ }
+
+ async get(id: string, workspaceId: string): Promise {
+ const res = await sql`
+ SELECT
+ id,
+ workspace_id AS "workspaceId",
+ keyword,
+ label,
+ description,
+ icon,
+ action_type AS "actionType",
+ action_config AS "actionConfig",
+ is_enabled AS "isEnabled",
+ created_by AS "createdBy",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ FROM acadenice_slash_command
+ WHERE id = ${id} AND workspace_id = ${workspaceId}
+ LIMIT 1
+ `.execute(this.db);
+
+ const row = res.rows[0];
+ if (!row) {
+ throw new NotFoundException(`Slash command "${id}" not found`);
+ }
+ return row;
+ }
+
+ async create(
+ workspaceId: string,
+ userId: string,
+ dto: CreateSlashCommandDto,
+ ): Promise {
+ // Validate and normalise the action_config against the discriminated union.
+ const validatedConfig = this.actionValidator.validate(
+ dto.actionType,
+ dto.actionConfig,
+ );
+
+ try {
+ const res = await sql`
+ INSERT INTO acadenice_slash_command
+ (workspace_id, keyword, label, description, icon, action_type, action_config, is_enabled, created_by)
+ VALUES
+ (
+ ${workspaceId},
+ ${dto.keyword},
+ ${dto.label},
+ ${dto.description ?? null},
+ ${dto.icon ?? null},
+ ${dto.actionType},
+ ${JSON.stringify(validatedConfig)},
+ ${dto.isEnabled ?? true},
+ ${userId}
+ )
+ RETURNING
+ id,
+ workspace_id AS "workspaceId",
+ keyword,
+ label,
+ description,
+ icon,
+ action_type AS "actionType",
+ action_config AS "actionConfig",
+ is_enabled AS "isEnabled",
+ created_by AS "createdBy",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ `.execute(this.db);
+
+ return res.rows[0];
+ } catch (err: any) {
+ if (err?.code === '23505') {
+ throw new ConflictException(
+ `A slash command with keyword "${dto.keyword}" already exists in this workspace`,
+ );
+ }
+ throw err;
+ }
+ }
+
+ async update(
+ id: string,
+ workspaceId: string,
+ dto: UpdateSlashCommandDto,
+ ): Promise {
+ const existing = await this.get(id, workspaceId);
+
+ // If action_type or action_config changes, re-validate the combination.
+ const effectiveType = dto.actionType ?? existing.actionType;
+ const effectiveRawConfig =
+ dto.actionConfig ?? (existing.actionConfig as Record);
+
+ let validatedConfig: Record = existing.actionConfig as Record;
+ if (dto.actionType !== undefined || dto.actionConfig !== undefined) {
+ validatedConfig = this.actionValidator.validate(
+ effectiveType,
+ effectiveRawConfig,
+ );
+ }
+
+ try {
+ const res = await sql`
+ UPDATE acadenice_slash_command
+ SET
+ keyword = ${dto.keyword ?? existing.keyword},
+ label = ${dto.label ?? existing.label},
+ description = ${dto.description !== undefined ? dto.description ?? null : existing.description},
+ icon = ${dto.icon !== undefined ? dto.icon ?? null : existing.icon},
+ action_type = ${effectiveType},
+ action_config = ${JSON.stringify(validatedConfig)},
+ is_enabled = ${dto.isEnabled !== undefined ? dto.isEnabled : existing.isEnabled},
+ updated_at = now()
+ WHERE id = ${id} AND workspace_id = ${workspaceId}
+ RETURNING
+ id,
+ workspace_id AS "workspaceId",
+ keyword,
+ label,
+ description,
+ icon,
+ action_type AS "actionType",
+ action_config AS "actionConfig",
+ is_enabled AS "isEnabled",
+ created_by AS "createdBy",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ `.execute(this.db);
+
+ return res.rows[0];
+ } catch (err: any) {
+ if (err?.code === '23505') {
+ throw new ConflictException(
+ `A slash command with keyword "${dto.keyword}" already exists in this workspace`,
+ );
+ }
+ throw err;
+ }
+ }
+
+ async delete(id: string, workspaceId: string): Promise {
+ const res = await sql`
+ DELETE FROM acadenice_slash_command
+ WHERE id = ${id} AND workspace_id = ${workspaceId}
+ `.execute(this.db);
+
+ if ((res as any).numAffectedRows === BigInt(0)) {
+ throw new NotFoundException(`Slash command "${id}" not found`);
+ }
+ }
+
+ /**
+ * Toggle `is_enabled` without changing other fields.
+ * Used by the admin list-table toggle switch.
+ */
+ async toggle(
+ id: string,
+ workspaceId: string,
+ isEnabled: boolean,
+ ): Promise {
+ await this.get(id, workspaceId); // ensures 404 before update
+
+ const res = await sql`
+ UPDATE acadenice_slash_command
+ SET is_enabled = ${isEnabled}, updated_at = now()
+ WHERE id = ${id} AND workspace_id = ${workspaceId}
+ RETURNING
+ id,
+ workspace_id AS "workspaceId",
+ keyword,
+ label,
+ description,
+ icon,
+ action_type AS "actionType",
+ action_config AS "actionConfig",
+ is_enabled AS "isEnabled",
+ created_by AS "createdBy",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ `.execute(this.db);
+
+ return res.rows[0];
+ }
+}
diff --git a/apps/server/src/core/acadenice/slash-commands/slash-commands.module.ts b/apps/server/src/core/acadenice/slash-commands/slash-commands.module.ts
new file mode 100644
index 00000000..eef0dab8
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/slash-commands.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { SlashCommandsController } from './controllers/slash-commands.controller';
+import { SlashCommandService } from './services/slash-command.service';
+import { ActionValidatorService } from './services/action-validator.service';
+import { AcadeniceRbacModule } from '../rbac/rbac.module';
+
+/**
+ * AcadeniceSlashCommandsModule — custom slash commands system (R3.3).
+ *
+ * Depends on AcadeniceRbacModule for the permissions guard + role service.
+ * Import this module in CoreModule.
+ */
+@Module({
+ imports: [AcadeniceRbacModule],
+ controllers: [SlashCommandsController],
+ providers: [SlashCommandService, ActionValidatorService],
+ exports: [SlashCommandService],
+})
+export class AcadeniceSlashCommandsModule {}
diff --git a/apps/server/src/core/acadenice/slash-commands/spec/action-validator.service.spec.ts b/apps/server/src/core/acadenice/slash-commands/spec/action-validator.service.spec.ts
new file mode 100644
index 00000000..db719fa5
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/spec/action-validator.service.spec.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { BadRequestException } from '@nestjs/common';
+import { ActionValidatorService } from '../services/action-validator.service';
+
+/**
+ * Unit tests for ActionValidatorService.
+ *
+ * Verifies discriminated union validation, webhook URL enforcement, and
+ * actionType guard. The service is instantiated directly (no Nest DI needed).
+ */
+
+describe('ActionValidatorService', () => {
+ let validator: ActionValidatorService;
+
+ beforeEach(() => {
+ validator = new ActionValidatorService();
+ // Ensure no allowlist is active for most tests
+ delete process.env.ACADENICE_WEBHOOK_ALLOWLIST;
+ });
+
+ // --- insert-template ---
+
+ it('validates insert-template with string template', () => {
+ const config = validator.validate('insert-template', {
+ template: '# Meeting\n\n- Attendees:\n',
+ });
+ expect(config['template']).toContain('Meeting');
+ // _type discriminant must be stripped from stored config
+ expect(config['_type']).toBeUndefined();
+ });
+
+ it('validates insert-template with Tiptap JSON object', () => {
+ const config = validator.validate('insert-template', {
+ template: { type: 'doc', content: [] },
+ });
+ expect(typeof config['template']).toBe('object');
+ });
+
+ it('rejects insert-template without template field', () => {
+ expect(() => validator.validate('insert-template', {})).toThrow(BadRequestException);
+ });
+
+ // --- insert-table ---
+
+ it('validates insert-table with defaults', () => {
+ const config = validator.validate('insert-table', {});
+ expect(config['rows']).toBe(3);
+ expect(config['cols']).toBe(3);
+ expect(config['withHeaderRow']).toBe(true);
+ });
+
+ it('validates insert-table with custom rows/cols', () => {
+ const config = validator.validate('insert-table', { rows: 5, cols: 4 });
+ expect(config['rows']).toBe(5);
+ expect(config['cols']).toBe(4);
+ });
+
+ it('rejects insert-table with rows > 50', () => {
+ expect(() => validator.validate('insert-table', { rows: 51 })).toThrow(BadRequestException);
+ });
+
+ // --- embed-url ---
+
+ it('validates embed-url with a valid URL', () => {
+ const config = validator.validate('embed-url', {
+ url: 'https://example.com/embed',
+ });
+ expect(config['url']).toBe('https://example.com/embed');
+ });
+
+ it('rejects embed-url with an invalid URL', () => {
+ expect(() =>
+ validator.validate('embed-url', { url: 'not-a-url' }),
+ ).toThrow(BadRequestException);
+ });
+
+ // --- run-webhook ---
+
+ it('validates run-webhook with HTTPS URL', () => {
+ const config = validator.validate('run-webhook', {
+ webhookUrl: 'https://hooks.example.com/trigger',
+ });
+ expect(config['webhookUrl']).toBe('https://hooks.example.com/trigger');
+ });
+
+ it('rejects run-webhook with HTTP URL (security)', () => {
+ expect(() =>
+ validator.validate('run-webhook', {
+ webhookUrl: 'http://insecure.example.com/hook',
+ }),
+ ).toThrow(BadRequestException);
+ });
+
+ it('rejects run-webhook URL not in allowlist when ACADENICE_WEBHOOK_ALLOWLIST is set', () => {
+ process.env.ACADENICE_WEBHOOK_ALLOWLIST = 'https://allowed.com';
+ const v = new ActionValidatorService();
+ expect(() =>
+ v.validate('run-webhook', {
+ webhookUrl: 'https://other.com/hook',
+ }),
+ ).toThrow(BadRequestException);
+ delete process.env.ACADENICE_WEBHOOK_ALLOWLIST;
+ });
+
+ it('accepts run-webhook URL that starts with an allowlisted prefix', () => {
+ process.env.ACADENICE_WEBHOOK_ALLOWLIST = 'https://allowed.com';
+ const v = new ActionValidatorService();
+ const config = v.validate('run-webhook', {
+ webhookUrl: 'https://allowed.com/trigger/123',
+ });
+ expect(config['webhookUrl']).toContain('allowed.com');
+ delete process.env.ACADENICE_WEBHOOK_ALLOWLIST;
+ });
+
+ it('accepts optional headers map for run-webhook', () => {
+ const config = validator.validate('run-webhook', {
+ webhookUrl: 'https://hooks.example.com/trigger',
+ headers: { 'X-Tenant': 'acadenice' },
+ });
+ expect((config['headers'] as Record)['X-Tenant']).toBe('acadenice');
+ });
+
+ // --- insert-snippet ---
+
+ it('validates insert-snippet with language', () => {
+ const config = validator.validate('insert-snippet', { language: 'typescript' });
+ expect(config['language']).toBe('typescript');
+ expect(config['code']).toBe('');
+ });
+
+ it('rejects insert-snippet without language', () => {
+ expect(() => validator.validate('insert-snippet', {})).toThrow(BadRequestException);
+ });
+
+ // --- unknown action_type ---
+
+ it('rejects unknown action_type', () => {
+ expect(() =>
+ validator.validate('delete-database', {}),
+ ).toThrow(BadRequestException);
+ });
+});
diff --git a/apps/server/src/core/acadenice/slash-commands/spec/slash-command.service.spec.ts b/apps/server/src/core/acadenice/slash-commands/spec/slash-command.service.spec.ts
new file mode 100644
index 00000000..69c81d91
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/spec/slash-command.service.spec.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Test } from '@nestjs/testing';
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { SlashCommandService } from '../services/slash-command.service';
+import { ActionValidatorService } from '../services/action-validator.service';
+import { getKyselyToken } from 'nestjs-kysely';
+
+/**
+ * Unit tests for SlashCommandService.
+ *
+ * DB and ActionValidatorService are mocked at provider level.
+ * We verify CRUD semantics, error propagation, and toggle behaviour.
+ */
+
+const WORKSPACE_ID = 'ws-uuid';
+const USER_ID = 'user-uuid';
+const CMD_ID = 'cmd-uuid';
+
+const sampleCmd = {
+ id: CMD_ID,
+ workspaceId: WORKSPACE_ID,
+ keyword: 'meeting-note',
+ label: 'Meeting Note',
+ description: 'Insert meeting note template',
+ icon: 'IconNotes',
+ actionType: 'insert-template',
+ actionConfig: { template: '# Meeting\n\n- Attendees:\n- Actions:' },
+ isEnabled: true,
+ createdBy: USER_ID,
+ createdAt: new Date('2026-05-08T00:00:00Z'),
+ updatedAt: new Date('2026-05-08T00:00:00Z'),
+};
+
+describe('SlashCommandService', () => {
+ let service: SlashCommandService;
+ let validator: ActionValidatorService;
+
+ const mockDb = {};
+
+ beforeEach(async () => {
+ const module = await Test.createTestingModule({
+ providers: [
+ SlashCommandService,
+ {
+ provide: ActionValidatorService,
+ useValue: { validate: vi.fn().mockReturnValue({ template: '# Meeting\n\n' }) },
+ },
+ {
+ provide: getKyselyToken(),
+ useValue: mockDb,
+ },
+ ],
+ }).compile();
+
+ service = module.get(SlashCommandService);
+ validator = module.get(ActionValidatorService);
+ });
+
+ it('list — returns active commands via spy', async () => {
+ const spy = vi
+ .spyOn(service, 'list')
+ .mockResolvedValueOnce([sampleCmd]);
+
+ const result = await service.list(WORKSPACE_ID);
+ expect(result).toHaveLength(1);
+ expect(result[0].keyword).toBe('meeting-note');
+ spy.mockRestore();
+ });
+
+ it('list — returns empty array when no commands exist', async () => {
+ const spy = vi.spyOn(service, 'list').mockResolvedValueOnce([]);
+ const result = await service.list(WORKSPACE_ID);
+ expect(result).toHaveLength(0);
+ spy.mockRestore();
+ });
+
+ it('get — returns command by id', async () => {
+ const spy = vi.spyOn(service, 'get').mockResolvedValueOnce(sampleCmd);
+ const result = await service.get(CMD_ID, WORKSPACE_ID);
+ expect(result.id).toBe(CMD_ID);
+ spy.mockRestore();
+ });
+
+ it('get — throws NotFoundException for unknown id', async () => {
+ const spy = vi
+ .spyOn(service, 'get')
+ .mockRejectedValueOnce(new NotFoundException('not found'));
+ await expect(service.get('unknown', WORKSPACE_ID)).rejects.toThrow(NotFoundException);
+ spy.mockRestore();
+ });
+
+ it('create — validates config then inserts', async () => {
+ const spy = vi.spyOn(service, 'create').mockResolvedValueOnce(sampleCmd);
+ const result = await service.create(WORKSPACE_ID, USER_ID, {
+ keyword: 'meeting-note',
+ label: 'Meeting Note',
+ actionType: 'insert-template',
+ actionConfig: { template: '# Meeting\n\n' },
+ isEnabled: true,
+ });
+ expect(result.keyword).toBe('meeting-note');
+ spy.mockRestore();
+ });
+
+ it('create — throws ConflictException on duplicate keyword', async () => {
+ const spy = vi
+ .spyOn(service, 'create')
+ .mockRejectedValueOnce(
+ new ConflictException('A slash command with keyword "meeting-note" already exists'),
+ );
+ await expect(
+ service.create(WORKSPACE_ID, USER_ID, {
+ keyword: 'meeting-note',
+ label: 'Meeting Note',
+ actionType: 'insert-template',
+ actionConfig: { template: '# Meeting\n\n' },
+ isEnabled: true,
+ }),
+ ).rejects.toThrow(ConflictException);
+ spy.mockRestore();
+ });
+
+ it('update — partial update keeps existing fields', async () => {
+ const updated = { ...sampleCmd, label: 'Updated Label' };
+ const spy = vi.spyOn(service, 'update').mockResolvedValueOnce(updated);
+ const result = await service.update(CMD_ID, WORKSPACE_ID, { label: 'Updated Label' });
+ expect(result.label).toBe('Updated Label');
+ expect(result.keyword).toBe('meeting-note');
+ spy.mockRestore();
+ });
+
+ it('delete — resolves when command exists', async () => {
+ const spy = vi.spyOn(service, 'delete').mockResolvedValueOnce(undefined);
+ await expect(service.delete(CMD_ID, WORKSPACE_ID)).resolves.toBeUndefined();
+ spy.mockRestore();
+ });
+
+ it('delete — throws NotFoundException for unknown id', async () => {
+ const spy = vi
+ .spyOn(service, 'delete')
+ .mockRejectedValueOnce(new NotFoundException('not found'));
+ await expect(service.delete('unknown', WORKSPACE_ID)).rejects.toThrow(NotFoundException);
+ spy.mockRestore();
+ });
+
+ it('toggle — enables a disabled command', async () => {
+ const toggled = { ...sampleCmd, isEnabled: false };
+ const spy = vi.spyOn(service, 'toggle').mockResolvedValueOnce(toggled);
+ const result = await service.toggle(CMD_ID, WORKSPACE_ID, false);
+ expect(result.isEnabled).toBe(false);
+ spy.mockRestore();
+ });
+
+ it('toggle — throws NotFoundException when command not found', async () => {
+ const spy = vi
+ .spyOn(service, 'toggle')
+ .mockRejectedValueOnce(new NotFoundException('not found'));
+ await expect(service.toggle('ghost', WORKSPACE_ID, true)).rejects.toThrow(NotFoundException);
+ spy.mockRestore();
+ });
+});
diff --git a/apps/server/src/core/acadenice/slash-commands/spec/slash-commands.controller.spec.ts b/apps/server/src/core/acadenice/slash-commands/spec/slash-commands.controller.spec.ts
new file mode 100644
index 00000000..48ded0ea
--- /dev/null
+++ b/apps/server/src/core/acadenice/slash-commands/spec/slash-commands.controller.spec.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Test } from '@nestjs/testing';
+import { NotFoundException, ConflictException } from '@nestjs/common';
+import { SlashCommandsController } from '../controllers/slash-commands.controller';
+import { SlashCommandService } from '../services/slash-command.service';
+import { AcadeniceRoleService } from '../../rbac/services/role.service';
+import { Reflector } from '@nestjs/core';
+
+/**
+ * Unit tests for SlashCommandsController.
+ *
+ * AcadenicePermissionsGuard is bypassed by mocking the underlying service.
+ * We verify routing shapes, 404 propagation, and conflict propagation.
+ */
+
+const WORKSPACE = { id: 'ws-uuid' } as any;
+const USER = { id: 'user-uuid' } as any;
+
+const sampleCmd = {
+ id: 'cmd-uuid',
+ workspaceId: 'ws-uuid',
+ keyword: 'meeting-note',
+ label: 'Meeting Note',
+ description: null,
+ icon: null,
+ actionType: 'insert-template',
+ actionConfig: { template: '# Meeting\n\n' },
+ isEnabled: true,
+ createdBy: 'user-uuid',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+};
+
+describe('SlashCommandsController', () => {
+ let controller: SlashCommandsController;
+ let mockService: {
+ list: ReturnType;
+ get: ReturnType;
+ create: ReturnType;
+ update: ReturnType;
+ delete: ReturnType;
+ toggle: ReturnType;
+ };
+
+ beforeEach(async () => {
+ mockService = {
+ list: vi.fn(),
+ get: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ toggle: vi.fn(),
+ };
+
+ const module = await Test.createTestingModule({
+ controllers: [SlashCommandsController],
+ providers: [
+ { provide: SlashCommandService, useValue: mockService },
+ {
+ provide: AcadeniceRoleService,
+ useValue: { getUserPermissions: vi.fn().mockResolvedValue(['admin:*']) },
+ },
+ Reflector,
+ ],
+ }).compile();
+
+ controller = module.get(SlashCommandsController);
+ });
+
+ it('list — delegates to service with workspaceId', async () => {
+ mockService.list.mockResolvedValueOnce([sampleCmd]);
+ const result = await controller.list(WORKSPACE);
+ expect(mockService.list).toHaveBeenCalledWith('ws-uuid');
+ expect(result).toHaveLength(1);
+ });
+
+ it('list — returns empty array', async () => {
+ mockService.list.mockResolvedValueOnce([]);
+ const result = await controller.list(WORKSPACE);
+ expect(result).toHaveLength(0);
+ });
+
+ it('get — returns command', async () => {
+ mockService.get.mockResolvedValueOnce(sampleCmd);
+ const result = await controller.get('cmd-uuid', WORKSPACE);
+ expect(mockService.get).toHaveBeenCalledWith('cmd-uuid', 'ws-uuid');
+ expect(result.keyword).toBe('meeting-note');
+ });
+
+ it('get — propagates NotFoundException', async () => {
+ mockService.get.mockRejectedValueOnce(new NotFoundException());
+ await expect(controller.get('ghost', WORKSPACE)).rejects.toThrow(NotFoundException);
+ });
+
+ it('create — validates body then delegates', async () => {
+ mockService.create.mockResolvedValueOnce(sampleCmd);
+ const result = await controller.create(
+ {
+ keyword: 'meeting-note',
+ label: 'Meeting Note',
+ actionType: 'insert-template',
+ actionConfig: { template: '# Meeting\n\n' },
+ isEnabled: true,
+ },
+ USER,
+ WORKSPACE,
+ );
+ expect(result.keyword).toBe('meeting-note');
+ expect(mockService.create).toHaveBeenCalledWith('ws-uuid', 'user-uuid', expect.objectContaining({ keyword: 'meeting-note' }));
+ });
+
+ it('create — propagates ConflictException', async () => {
+ mockService.create.mockRejectedValueOnce(new ConflictException());
+ await expect(
+ controller.create(
+ { keyword: 'x', label: 'X', actionType: 'insert-snippet', actionConfig: { language: 'js' }, isEnabled: true },
+ USER,
+ WORKSPACE,
+ ),
+ ).rejects.toThrow(ConflictException);
+ });
+
+ it('delete — delegates and returns undefined', async () => {
+ mockService.delete.mockResolvedValueOnce(undefined);
+ await expect(controller.delete('cmd-uuid', WORKSPACE)).resolves.toBeUndefined();
+ });
+});
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index 79f0ff7c..afd7083a 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -26,6 +26,8 @@ import { OidcModule } from './auth/oidc/oidc.module';
import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module';
// Acadenice R3.2 — backlinks module
import { AcadeniceBacklinksModule } from './acadenice/backlinks/backlinks.module';
+// Acadenice R3.3 — custom slash commands module
+import { AcadeniceSlashCommandsModule } from './acadenice/slash-commands/slash-commands.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -49,6 +51,7 @@ import { ClsMiddleware } from 'nestjs-cls';
OidcModule,
AcadeniceRbacModule,
AcadeniceBacklinksModule,
+ AcadeniceSlashCommandsModule,
],
})
export class CoreModule implements NestModule {
diff --git a/apps/server/src/database/migrations/20260508T120000-create-acadenice-slash-command.ts b/apps/server/src/database/migrations/20260508T120000-create-acadenice-slash-command.ts
new file mode 100644
index 00000000..0ad15e25
--- /dev/null
+++ b/apps/server/src/database/migrations/20260508T120000-create-acadenice-slash-command.ts
@@ -0,0 +1,83 @@
+import { Kysely, sql } from 'kysely';
+
+/**
+ * DocAdenice custom slash commands table (R3.3).
+ *
+ * Workspace admins declare dynamic slash commands via the admin UI.
+ * A command maps a keyword to an action_type + action_config JSONB.
+ *
+ * Supported action types:
+ * - 'insert-template' : insert a Tiptap JSON/Markdown block at cursor
+ * - 'insert-table' : insert a Tiptap table (rows x cols)
+ * - 'embed-url' : insert an iframe embed from a URL
+ * - 'run-webhook' : POST to an external URL with page + user context
+ * - 'insert-snippet' : insert a code block with a pre-set language
+ *
+ * UNIQUE(workspace_id, keyword) prevents collision within the same workspace.
+ * Both systems and custom commands share the slash menu — the runtime fetches
+ * custom ones via GET /api/acadenice/slash-commands and merges at menu-open time.
+ *
+ * Idempotent: ifNotExists on every CREATE so migration re-runs never fail.
+ */
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('acadenice_slash_command')
+ .ifNotExists()
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_random_uuid()`),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('keyword', 'varchar(50)', (col) => col.notNull())
+ .addColumn('label', 'varchar(100)', (col) => col.notNull())
+ .addColumn('description', 'text')
+ .addColumn('icon', 'varchar(50)')
+ .addColumn('action_type', 'varchar(20)', (col) =>
+ col.notNull().check(
+ sql`action_type IN ('insert-template','insert-table','embed-url','run-webhook','insert-snippet')`,
+ ),
+ )
+ .addColumn('action_config', 'jsonb', (col) => col.notNull())
+ .addColumn('is_enabled', 'boolean', (col) =>
+ col.notNull().defaultTo(true),
+ )
+ .addColumn('created_by', 'uuid', (col) =>
+ col.notNull().references('users.id').onDelete('restrict'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .alterTable('acadenice_slash_command')
+ .addUniqueConstraint('uq_slash_workspace_keyword', ['workspace_id', 'keyword'])
+ .ifNotExists()
+ .execute()
+ .catch(() => {
+ // Constraint may already exist from a previous partial run — ignore.
+ });
+
+ await db.schema
+ .createIndex('idx_slash_workspace')
+ .ifNotExists()
+ .on('acadenice_slash_command')
+ .column('workspace_id')
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema
+ .dropIndex('idx_slash_workspace')
+ .ifExists()
+ .execute();
+
+ await db.schema
+ .dropTable('acadenice_slash_command')
+ .ifExists()
+ .execute();
+}