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:
parent
8cd57f93b3
commit
4e2af88144
29 changed files with 2632 additions and 3 deletions
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue