feat(acadenice): add custom slash commands system for R3.3

Workspace admins can declare dynamic /keyword commands via settings UI
without recompile. Five action types: insert-template, insert-table,
embed-url, run-webhook, insert-snippet. Webhook security: HTTPS-only,
ACADENICE_WEBHOOK_ALLOWLIST allowlist, 10s timeout, no redirects, 1MB cap.
New permission slash_commands:manage added to catalogue (23 perms) and
seeded to Owner + Admin roles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 01:06:11 +02:00
parent 8cd57f93b3
commit 4e2af88144
29 changed files with 2632 additions and 3 deletions

View file

@ -1050,5 +1050,52 @@
"backlinks.untitled": "Untitled", "backlinks.untitled": "Untitled",
"wikilink.suggestion.no_results": "No matching pages", "wikilink.suggestion.no_results": "No matching pages",
"wikilink.suggestion.type_to_search": "Type to search 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."
} }

View file

@ -1005,5 +1005,52 @@
"backlinks.untitled": "Sans titre", "backlinks.untitled": "Sans titre",
"wikilink.suggestion.no_results": "Aucune page correspondante", "wikilink.suggestion.no_results": "Aucune page correspondante",
"wikilink.suggestion.type_to_search": "Tapez pour rechercher des pages...", "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."
} }

View file

@ -48,6 +48,8 @@ import VerifyEmail from "@/ee/pages/verify-email.tsx";
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page"; import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page"; import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel"; 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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -133,6 +135,8 @@ export default function App() {
path={"users/:userId/roles"} path={"users/:userId/roles"}
element={<UserRolesPanelPage />} element={<UserRolesPanelPage />}
/> />
{/* Acadenice R3.3 — custom slash commands admin */}
<Route path={"slash-commands"} element={<SlashCommandsPage />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>

View file

@ -16,6 +16,7 @@ import {
IconHistory, IconHistory,
IconShieldCheck, IconShieldCheck,
IconShieldLock, IconShieldLock,
IconSlash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
@ -108,6 +109,13 @@ const groupedData: DataGroup[] = [
path: "/settings/roles", path: "/settings/roles",
acadeniceCanManageRoles: true, 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: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{ {

View file

@ -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<typeof vi.fn>).mockReturnValue(mockPermissions);
(queries.useDeleteSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useToggleSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useCreateSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useUpdateSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useSlashCommandsQuery as ReturnType<typeof vi.fn>).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(
<AllProviders>
<SlashCommandList />
</AllProviders>,
);
expect(screen.getByText("/meeting-note")).toBeDefined();
expect(screen.getByText("Meeting Note")).toBeDefined();
});
it("shows loader while loading", () => {
setup({ isLoading: true, data: undefined });
render(
<AllProviders>
<SlashCommandList />
</AllProviders>,
);
// 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(
<AllProviders>
<SlashCommandList />
</AllProviders>,
);
expect(screen.getByText(/error|fail/i)).toBeDefined();
});
it("shows empty state when no commands", () => {
setup({ data: [] });
render(
<AllProviders>
<SlashCommandList />
</AllProviders>,
);
// Empty state text should appear
const cell = document.querySelector("td");
expect(cell).toBeTruthy();
});
it("shows create button", () => {
setup();
render(
<AllProviders>
<SlashCommandList />
</AllProviders>,
);
const btn = screen.getByRole("button", { name: /create|new|add/i });
expect(btn).toBeDefined();
});
});

View file

@ -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<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useToggleSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useCreateSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useUpdateSlashCommandMutation as ReturnType<typeof vi.fn>).mockReturnValue(noopMutation);
(queries.useSlashCommandsQuery as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({
permissions: ["slash_commands:manage"],
hasPermission: (p: string) => p === "slash_commands:manage",
canManageRoles: true,
isLoading: false,
});
render(
<AllProviders initialEntries={["/settings/slash-commands"]}>
<SlashCommandsPage />
</AllProviders>,
);
// 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<typeof vi.fn>).mockReturnValue({
permissions: [],
hasPermission: () => false,
canManageRoles: false,
isLoading: false,
});
render(
<AllProviders initialEntries={["/settings/slash-commands"]}>
<SlashCommandsPage />
</AllProviders>,
);
expect(screen.getByText(/access denied|permission/i)).toBeDefined();
});
it("renders page title in document", () => {
(rbacHook.useAcadenicePermissions as ReturnType<typeof vi.fn>).mockReturnValue({
permissions: ["admin:*"],
hasPermission: () => true,
canManageRoles: true,
isLoading: false,
});
render(
<AllProviders>
<SlashCommandsPage />
</AllProviders>,
);
// SettingsTitle renders an h1 or heading
const heading = document.querySelector("h1, h2, [role='heading']");
expect(heading).toBeTruthy();
});
});

View file

@ -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<FormValues>({
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<string, unknown>;
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<string, unknown> {
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<string, string> | undefined;
if (values.webhookHeaders.trim()) {
try {
headers = JSON.parse(values.webhookHeaders) as Record<string, string>;
} 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 (
<Modal
opened={opened}
onClose={onClose}
title={
initialValues
? t("slash_commands.edit_title")
: t("slash_commands.create_title")
}
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="sm">
<TextInput
label={t("slash_commands.keyword_label")}
description={t("slash_commands.keyword_description")}
placeholder="meeting-note"
{...form.getInputProps("keyword")}
disabled={!!initialValues}
/>
<TextInput
label={t("slash_commands.label_label")}
placeholder="Meeting Note"
{...form.getInputProps("label")}
/>
<Textarea
label={t("slash_commands.description_label")}
placeholder={t("slash_commands.description_placeholder")}
autosize
minRows={2}
{...form.getInputProps("description")}
/>
<TextInput
label={t("slash_commands.icon_label")}
description={t("slash_commands.icon_description")}
placeholder="IconNotes"
{...form.getInputProps("icon")}
/>
<Select
label={t("slash_commands.action_type_label")}
data={ACTION_TYPE_OPTIONS}
{...form.getInputProps("actionType")}
/>
<Switch
label={t("slash_commands.enabled_label")}
{...form.getInputProps("isEnabled", { type: "checkbox" })}
/>
<Divider label={t("slash_commands.action_config_section")} />
{/* Polymorphic config fields */}
{actionType === "insert-template" && (
<Textarea
label={t("slash_commands.template_label")}
description={t("slash_commands.template_description")}
autosize
minRows={4}
fontFamily="monospace"
{...form.getInputProps("template")}
/>
)}
{actionType === "insert-table" && (
<>
<NumberInput
label={t("slash_commands.rows_label")}
min={1}
max={50}
{...form.getInputProps("rows")}
/>
<NumberInput
label={t("slash_commands.cols_label")}
min={1}
max={20}
{...form.getInputProps("cols")}
/>
<Switch
label={t("slash_commands.header_row_label")}
{...form.getInputProps("withHeaderRow", { type: "checkbox" })}
/>
</>
)}
{actionType === "embed-url" && (
<TextInput
label={t("slash_commands.url_label")}
placeholder="https://example.com/embed"
{...form.getInputProps("url")}
/>
)}
{actionType === "run-webhook" && (
<>
<Alert color="yellow" title={t("slash_commands.webhook_security_title")}>
{t("slash_commands.webhook_security_note")}
</Alert>
<TextInput
label={t("slash_commands.webhook_url_label")}
placeholder="https://hooks.example.com/trigger"
{...form.getInputProps("webhookUrl")}
/>
<Textarea
label={t("slash_commands.webhook_headers_label")}
description={t("slash_commands.webhook_headers_description")}
placeholder={'{"X-Tenant": "acadenice"}'}
autosize
minRows={2}
fontFamily="monospace"
{...form.getInputProps("webhookHeaders")}
/>
</>
)}
{actionType === "insert-snippet" && (
<>
<TextInput
label={t("slash_commands.language_label")}
placeholder="typescript"
{...form.getInputProps("language")}
/>
<Textarea
label={t("slash_commands.snippet_code_label")}
description={t("slash_commands.snippet_code_description")}
autosize
minRows={3}
fontFamily="monospace"
{...form.getInputProps("code")}
/>
</>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} disabled={isLoading}>
{t("Cancel")}
</Button>
<Button type="submit" loading={isLoading}>
{initialValues ? t("Save") : t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View file

@ -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<string, string> = {
"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<SlashCommandDto | null>(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 <Loader size="sm" />;
}
if (error) {
return (
<Alert color="red" title={t("slash_commands.load_error")}>
<Button variant="subtle" size="xs" onClick={() => refetch()}>
{t("Retry")}
</Button>
</Alert>
);
}
const rows =
commands && commands.length > 0 ? (
commands.map((cmd) => (
<Table.Tr key={cmd.id} data-testid={`slash-cmd-row-${cmd.id}`}>
<Table.Td>
<Code>/{cmd.keyword}</Code>
</Table.Td>
<Table.Td>{cmd.label}</Table.Td>
<Table.Td>
<Badge color={ACTION_TYPE_COLOR[cmd.actionType] ?? "gray"} variant="light">
{cmd.actionType}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip
label={
cmd.isEnabled
? t("slash_commands.disable_tooltip")
: t("slash_commands.enable_tooltip")
}
>
<Switch
checked={cmd.isEnabled}
onChange={(e) =>
toggleMutation.mutate({ id: cmd.id, isEnabled: e.currentTarget.checked })
}
aria-label={
cmd.isEnabled
? t("slash_commands.disable_tooltip")
: t("slash_commands.enable_tooltip")
}
/>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Tooltip label={t("Edit")}>
<ActionIcon
variant="subtle"
onClick={() => openEdit(cmd)}
aria-label={t("Edit")}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Delete")}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => {
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}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" size="sm" ta="center">
{t("slash_commands.empty_state")}
</Text>
</Table.Td>
</Table.Tr>
);
return (
<>
<Group justify="flex-end" mb="md">
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
{t("slash_commands.create_button")}
</Button>
</Group>
<Table highlightOnHover data-testid="slash-commands-table">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("slash_commands.col_keyword")}</Table.Th>
<Table.Th>{t("slash_commands.col_label")}</Table.Th>
<Table.Th>{t("slash_commands.col_action_type")}</Table.Th>
<Table.Th>{t("slash_commands.col_enabled")}</Table.Th>
<Table.Th>{t("Actions")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
<SlashCommandForm
opened={formOpen}
onClose={() => setFormOpen(false)}
onSubmit={handleFormSubmit}
initialValues={editTarget}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
</>
);
}

View file

@ -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 (
<>
<Helmet>
<title>
{t("slash_commands.page_title")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("slash_commands.page_title")} />
{!canManageSlashCommands && (
<Alert color="red" title={t("slash_commands.access_denied_title")}>
{t("slash_commands.access_denied_description")}
</Alert>
)}
{canManageSlashCommands && <SlashCommandList />}
</>
);
}

View file

@ -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<SlashCommandDto[], Error> {
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<SlashCommandDto[]>(SLASH_COMMANDS_QUERY_KEY);
qc.setQueryData<SlashCommandDto[]>(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 });
},
});
}

View file

@ -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<string, unknown>;
isEnabled: boolean;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface CreateSlashCommandPayload {
keyword: string;
label: string;
description?: string;
icon?: string;
actionType: SlashCommandDto['actionType'];
actionConfig: Record<string, unknown>;
isEnabled?: boolean;
}
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
const BASE = '/api/acadenice/slash-commands';
export const slashCommandsClient = {
list(): Promise<SlashCommandDto[]> {
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data);
},
get(id: string): Promise<SlashCommandDto> {
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data);
},
create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data);
},
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
return axios
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
.then((r) => r.data);
},
delete(id: string): Promise<void> {
return axios.delete(`${BASE}/${id}`).then(() => undefined);
},
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
return axios
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
.then((r) => r.data);
},
};

View file

@ -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");
});
});

View file

@ -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<typeof vi.fn>;
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));
});
});

View file

@ -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<ExecutionResult> {
// Delete the slash trigger text before inserting content
editor.chain().focus().deleteRange(range).run();
const cfg = cmd.actionConfig as Record<string, unknown>;
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<string, unknown>,
): 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<string, unknown>)
.run();
}
return { success: true };
}
function executeInsertTable(
editor: Editor,
cfg: Record<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): Promise<ExecutionResult> {
const webhookUrl = cfg["webhookUrl"] as string;
if (!webhookUrl) return { success: false, message: "No webhook URL configured" };
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((cfg["headers"] as Record<string, string>) ?? {}),
};
// 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 };
}
}

View file

@ -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,
});
});
},
}));
}

View file

@ -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<SlashCommandDto[], Error> {
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).
});
}

View file

@ -695,10 +695,28 @@ const CommandGroups: SlashMenuGroupedItemsType = {
export const getSuggestionItems = ({ export const getSuggestionItems = ({
query, query,
excludeItems, 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; query: string;
excludeItems?: Set<string>; excludeItems?: Set<string>;
customSlashItems?: SlashMenuItemType[];
}): SlashMenuGroupedItemsType => { }): 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 search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {}; const filteredGroups: SlashMenuGroupedItemsType = {};
@ -712,7 +730,7 @@ export const getSuggestionItems = ({
return false; return false;
}; };
for (const [group, items] of Object.entries(CommandGroups)) { for (const [group, items] of Object.entries(groups)) {
const filteredItems = items.filter((item) => { const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false; if (excludeItems?.has(item.title)) return false;
return ( return (

View file

@ -50,6 +50,8 @@ export const PERMISSION_KEYS = [
// Meta // Meta
'roles:manage', 'roles:manage',
// Acadenice R3.3 — custom slash commands admin
'slash_commands:manage',
'admin:*', 'admin:*',
] as const; ] as const;
@ -173,6 +175,13 @@ export const PERMISSIONS_CATALOG: ReadonlyArray<PermissionDescriptor> = [
description: description:
'Manage acadenice_role definitions and user-role assignments', '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:*', key: 'admin:*',
group: 'meta', group: 'meta',

View file

@ -54,6 +54,8 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
'attachments:upload', 'attachments:upload',
'users:invite', 'users:invite',
'users:write', 'users:write',
// R3.3 — slash command management
'slash_commands:manage',
], ],
}, },
{ {

View file

@ -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<T>(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<SlashCommandDto[]> {
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<SlashCommandDto> {
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<SlashCommandDto> {
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<SlashCommandDto> {
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<void> {
return this.slashCommandService.delete(id, workspace.id);
}
}

View file

@ -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<typeof actionConfigSchema>;
// ---------------------------------------------------------------------------
// 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<typeof createSlashCommandSchema>;
// ---------------------------------------------------------------------------
// Update DTO — every field is optional
// ---------------------------------------------------------------------------
export const updateSlashCommandSchema = createSlashCommandSchema.partial();
export type UpdateSlashCommandDto = z.infer<typeof updateSlashCommandSchema>;
// ---------------------------------------------------------------------------
// 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<string, unknown>;
isEnabled: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>;
try {
parsed = actionConfigSchema.parse(payload) as Record<string, unknown>;
} 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.`,
);
}
}
}

View file

@ -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<SlashCommandDto[]> {
const rows = await sql<SlashCommandDto>`
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<SlashCommandDto> {
const res = await sql<SlashCommandDto>`
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<SlashCommandDto> {
// Validate and normalise the action_config against the discriminated union.
const validatedConfig = this.actionValidator.validate(
dto.actionType,
dto.actionConfig,
);
try {
const res = await sql<SlashCommandDto>`
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<SlashCommandDto> {
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<string, unknown>);
let validatedConfig: Record<string, unknown> = existing.actionConfig as Record<string, unknown>;
if (dto.actionType !== undefined || dto.actionConfig !== undefined) {
validatedConfig = this.actionValidator.validate(
effectiveType,
effectiveRawConfig,
);
}
try {
const res = await sql<SlashCommandDto>`
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<void> {
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<SlashCommandDto> {
await this.get(id, workspaceId); // ensures 404 before update
const res = await sql<SlashCommandDto>`
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];
}
}

View file

@ -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 {}

View file

@ -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<string, string>)['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);
});
});

View file

@ -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();
});
});

View file

@ -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<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
toggle: ReturnType<typeof vi.fn>;
};
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();
});
});

View file

@ -26,6 +26,8 @@ import { OidcModule } from './auth/oidc/oidc.module';
import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module'; import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module';
// Acadenice R3.2 — backlinks module // Acadenice R3.2 — backlinks module
import { AcadeniceBacklinksModule } from './acadenice/backlinks/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'; import { ClsMiddleware } from 'nestjs-cls';
@Module({ @Module({
@ -49,6 +51,7 @@ import { ClsMiddleware } from 'nestjs-cls';
OidcModule, OidcModule,
AcadeniceRbacModule, AcadeniceRbacModule,
AcadeniceBacklinksModule, AcadeniceBacklinksModule,
AcadeniceSlashCommandsModule,
], ],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {

View file

@ -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<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema
.dropIndex('idx_slash_workspace')
.ifExists()
.execute();
await db.schema
.dropTable('acadenice_slash_command')
.ifExists()
.execute();
}