Files
twenty/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool.ts
T
Félix Malfait de044f4b45 feat(ai-chat): add navigation menu item + webhook tool providers (#20759)
## Summary

Exposes two Twenty primitives to the AI chat that it could not
previously manage:

- **Navigation menu items** — workspace nav and personal favorites
(favorites are just nav items with `scope: 'user'`).
- **Webhooks** — full CRUD with a structured operations input (record +
metadata events).

Page layouts and workflow runs were originally in this PR but have been
split out — they touch heavier surfaces (21 widget configurations and
the workflow runner cycle, respectively) and deserve their own focused
PRs.

### Tool inventory (8 new tools across 2 providers)

| Provider | Tools |
|---|---|
| NavigationMenuItem | `list_`, `create_`, `update_`,
`delete_navigation_menu_item` |
| Webhook | `list_`, `create_`, `update_`, `delete_webhook` |

### Design notes

- Both providers follow the established **view-style pattern**: tool
workspace service lives in the entity module's `tools/` folder, is
provided + exported by the entity module, and `ToolProviderModule`
imports the entity module. No `@Global()` modules or injection tokens
introduced.
- `create_navigation_menu_item` uses a Zod `discriminatedUnion` on
`type` (`FOLDER` / `LINK` / `OBJECT` / `VIEW` / `RECORD` /
`PAGE_LAYOUT`). `scope: 'workspace' | 'user'` switches between shared
nav and personal favorites — the underlying
`NavigationMenuItemAccessService` enforces LAYOUTS for workspace writes.
- Webhook operations accept both record events (`{kind:'record', object,
event}` → `<object>.<event>`) and metadata events (`{kind:'metadata',
metadataName, operation}` → `metadata.<metadataName>.<operation>`).
- Permissions reuse existing flags (`LAYOUTS`, `API_KEYS_AND_WEBHOOKS`).
No new permission flags, no migrations.

### Category cleanup

- New: `ToolCategory.NAVIGATION_MENU_ITEM`, `ToolCategory.WEBHOOK`.
- `ToolCategory.VIEW_FIELD` → folded into `VIEW`. Same permission gate,
same domain — separate category was organizational drift.
- `navigate_app` action stays in `ToolCategory.ACTION` where it belongs.

### System prompt addition


[chat-system-prompts.const.ts](packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts)
now teaches the AI:
- Favorites are nav items with `scope: 'user'`.
- A default OBJECT nav item is auto-created with
`create_object_metadata` — don't double-create.

### One file = one export

Every new schema / type / util file has exactly one top-level export.

## Test plan

- [ ] `npx nx typecheck twenty-server` — passes
- [ ] Spin up locally and exercise via AI chat:
- [ ] "Pin the Companies view to my favorites in a folder called
Important." → `create_navigation_menu_item` (FOLDER, user) then (VIEW,
user, folderId)
- [ ] "Register a webhook to https://example.com firing when any person
is created or updated." → `create_webhook` with discriminated operations
- [ ] Verify workspace-scoped nav writes are denied for a user without
LAYOUTS permission
- [ ] Verify user-scoped nav writes work without LAYOUTS permission

## Follow-ups (separate PRs)

- Page layout tools (record-page, record-index, standalone) — needs
widget-config strategy.
- Workflow run tools (list, get, run, stop) — uses the workflow-runner
cycle path.
- Dashboard / page-layout tool unification —
`DashboardToolWorkspaceService` and a future
`PageLayoutToolWorkspaceService` both inject the same trio
(PageLayout/Tab/Widget services).
- Webhook Settings page reads from raw Apollo query — switch to the
metadata store so it refreshes when the AI mutates webhooks.
2026-05-22 17:27:06 +02:00

198 lines
6.9 KiB
TypeScript

import { z } from 'zod';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { navigationMenuItemScopeSchema } from 'src/engine/metadata-modules/navigation-menu-item/tools/schemas/navigation-menu-item-scope.schema';
import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-context.type';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const commonOptionalFields = {
icon: z
.string()
.optional()
.describe('Icon identifier (e.g. "IconStar", "IconFolder")'),
color: z.string().optional().describe('Optional hex colour'),
position: z
.number()
.optional()
.describe('Position among siblings; defaults to the end.'),
folderId: z
.string()
.uuid()
.optional()
.describe('Parent folder id, if the item should live inside a folder.'),
};
const requiredNameField = z
.string()
.trim()
.min(1)
.describe('Label shown in the sidebar.');
const derivedNameField = z
.string()
.trim()
.min(1)
.optional()
.describe(
"Optional custom label. If omitted, the sidebar shows the target's own name (object's plural label / view name / record identifier). Only pass this if the user explicitly wants a different label.",
);
const createNavigationMenuItemSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(NavigationMenuItemType.FOLDER),
scope: navigationMenuItemScopeSchema,
name: requiredNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.LINK),
scope: navigationMenuItemScopeSchema,
name: requiredNameField,
link: z.string().url().describe('Target URL'),
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.OBJECT),
scope: navigationMenuItemScopeSchema,
targetObjectMetadataId: z
.string()
.uuid()
.describe('Id of the object to pin'),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.VIEW),
scope: navigationMenuItemScopeSchema,
viewId: z.string().uuid().describe('Id of the view to pin'),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.RECORD),
scope: navigationMenuItemScopeSchema,
targetRecordId: z.string().uuid().describe('Id of the record to pin'),
targetObjectMetadataId: z
.string()
.uuid()
.describe("Id of the record's object metadata"),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.PAGE_LAYOUT),
scope: navigationMenuItemScopeSchema,
pageLayoutId: z.string().uuid().describe('Id of the page layout to pin'),
name: requiredNameField,
...commonOptionalFields,
}),
]);
type CreateNavigationMenuItemParams = z.infer<
typeof createNavigationMenuItemSchema
>;
const toServiceInput = (
params: CreateNavigationMenuItemParams,
userWorkspaceId: string | undefined,
): CreateNavigationMenuItemInput => {
const resolvedUserWorkspaceId =
params.scope === 'user' ? userWorkspaceId : undefined;
const base = {
type: params.type as NavigationMenuItemType,
userWorkspaceId: resolvedUserWorkspaceId,
icon: params.icon,
color: params.color,
position: params.position,
folderId: params.folderId,
};
switch (params.type) {
case NavigationMenuItemType.FOLDER:
return { ...base, name: params.name };
case NavigationMenuItemType.LINK:
return { ...base, name: params.name, link: params.link };
case NavigationMenuItemType.OBJECT:
return {
...base,
name: params.name,
targetObjectMetadataId: params.targetObjectMetadataId,
};
case NavigationMenuItemType.VIEW:
return { ...base, name: params.name, viewId: params.viewId };
case NavigationMenuItemType.RECORD:
return {
...base,
name: params.name,
targetRecordId: params.targetRecordId,
targetObjectMetadataId: params.targetObjectMetadataId,
};
case NavigationMenuItemType.PAGE_LAYOUT:
return {
...base,
name: params.name,
pageLayoutId: params.pageLayoutId,
};
}
};
export const createCreateNavigationMenuItemTool = (
deps: Pick<NavigationMenuItemToolDependencies, 'navigationMenuItemService'>,
context: NavigationMenuItemToolContext,
) => ({
name: 'create_navigation_menu_item' as const,
description: `Create a navigation menu item. With scope='user' it becomes a personal favorite for the current user; with scope='workspace' it is shared with everyone (requires LAYOUTS permission).
Type chooses the variant:
- FOLDER: a group to nest other items into (name required).
- LINK: an external URL pinned in the sidebar (name + link required).
- OBJECT: pins an object's standard view (label auto-derived from the object's plural name; only pass 'name' if the user wants a custom label).
- VIEW: pins a saved view (label auto-derived from the view's name; only pass 'name' for a custom label).
- RECORD: pins a single record (label auto-derived from the record's identifier; only pass 'name' for a custom label).
- PAGE_LAYOUT: pins a page layout, e.g. a dashboard (name required — no auto-derivation).
Note: creating a new custom object via create_object_metadata already auto-creates an OBJECT navigation menu item — do not double-create.`,
inputSchema: createNavigationMenuItemSchema,
execute: async (parameters: CreateNavigationMenuItemParams) => {
try {
if (parameters.scope === 'user' && !context.userWorkspaceId) {
return {
success: false,
message:
'Cannot create a user-scoped favorite without an authenticated user context.',
error: 'missing_user_workspace_id',
};
}
const created = await deps.navigationMenuItemService.create({
input: toServiceInput(parameters, context.userWorkspaceId),
workspaceId: context.workspaceId,
authUserWorkspaceId: context.userWorkspaceId,
});
return {
success: true,
message: `Navigation menu item ${created.id} (${created.type}) created`,
result: {
id: created.id,
type: created.type,
name: created.name,
scope: created.userWorkspaceId ? 'user' : 'workspace',
folderId: created.folderId,
position: created.position,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to create navigation menu item: ${message}`,
error: message,
};
}
},
});