de044f4b45
## 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.
198 lines
6.9 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
},
|
|
});
|