From 3cea19baf4fc58ed8fcff25e69b69fcf6f0d37d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 11 Dec 2025 15:05:42 +0100 Subject: [PATCH] feat(ai): add view management tools for AI chat (#16495) ## Summary Adds a new **VIEW** tool category for the AI chat, enabling it to work with views: - **get-views**: List views in the workspace, optionally filtered by object metadata ID - **get-view-query-parameters**: Convert a view's filters and sorts into GraphQL query parameters that can be passed to existing `find_*` data tools - **create-view**, **update-view**, **delete-view**: CRUD operations for view management ### Key design decisions 1. **No pagination duplication**: Instead of creating a `find-records-from-view` tool that would duplicate pagination logic, `get-view-query-parameters` returns filter/sort parameters that the AI can pass to existing record-fetching tools. 2. **Permission model**: - Read tools (get-views, get-view-query-parameters) are available to all users - Write tools require the `VIEW` permission - UNLISTED views can only be modified by their creator 3. **Leverages existing utilities**: Uses `computeRecordGqlOperationFilter` from `twenty-shared` for filter conversion. ### Files changed - Added `ViewToolProvider`, `ViewToolsFactory`, and `ViewQueryParamsService` - Added `VIEW` to `ToolCategory` enum and tool registry - Updated `chat-execution.service.ts` to include view tools in the catalog and pass viewId in browsing context - Extracted shared `formatValidationErrors` utility to reduce duplication ## Test plan - [x] Unit tests for `ViewToolsFactory` - [x] Unit tests for `ViewQueryParamsService` - [x] Lint and typecheck pass --- .../ai/components/LazyMarkdownRenderer.tsx | 139 +++--- .../zod-schemas/find-tool.zod-schema.ts | 32 ++ .../tool-provider/enums/tool-category.enum.ts | 1 + .../providers/view-tool.provider.ts | 55 +++ .../services/tool-registry.service.ts | 5 +- .../tool-provider/tool-provider.module.ts | 7 + .../utils/format-validation-errors.util.ts | 28 ++ .../services/chat-execution.service.ts | 16 +- .../tools/field-metadata-tools.factory.ts | 28 +- .../tools/object-metadata-tools.factory.ts | 28 +- .../view-query-params.service.spec.ts | 254 +++++++++++ .../services/view-query-params.service.ts | 144 ++++++ .../__tests__/view-tools.factory.spec.ts | 431 ++++++++++++++++++ .../view/tools/view-tools.factory.ts | 340 ++++++++++++++ .../metadata-modules/view/view.module.ts | 16 +- 15 files changed, 1384 insertions(+), 140 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/services/__tests__/view-query-params.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index 8ce63ac011..44be49ea8c 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -6,10 +6,62 @@ import { } from '@/ai/components/RecordLink'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { Fragment, lazy, Suspense, useMemo } from 'react'; +import { lazy, Suspense } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { isDefined } from 'twenty-shared/utils'; +const TextWithRecordLinks = ({ text }: { text: string }) => { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + RECORD_REFERENCE_REGEX.lastIndex = 0; + + let match; + + while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + const parsed = parseRecordReference(match[0]); + + if (isDefined(parsed)) { + parts.push( + , + ); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +}; + +const processChildrenForRecordLinks = ( + children: React.ReactNode, +): React.ReactNode => { + if (typeof children === 'string') { + return ; + } + + if (Array.isArray(children)) { + return children.map((child, index) => ( + {processChildrenForRecordLinks(child)} + )); + } + + return children; +}; + const MarkdownRenderer = lazy(async () => { const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([ import('react-markdown'), @@ -32,6 +84,10 @@ const MarkdownRenderer = lazy(async () => { {children}
), + p: ({ children }) =>

{processChildrenForRecordLinks(children)}

, + li: ({ children }) => ( +
  • {processChildrenForRecordLinks(children)}
  • + ), }} > {children} @@ -102,87 +158,12 @@ const LoadingSkeleton = () => { ); }; -const useTextWithRecordLinks = (text: string) => { - return useMemo(() => { - const parts: Array< - | string - | { type: 'record'; props: ReturnType } - > = []; - let lastIndex = 0; - - RECORD_REFERENCE_REGEX.lastIndex = 0; - - let match; - - while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - - const parsed = parseRecordReference(match[0]); - - if (isDefined(parsed)) { - parts.push({ type: 'record', props: parsed }); - } - - lastIndex = match.index + match[0].length; - } - - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - - return parts; - }, [text]); -}; - export const LazyMarkdownRenderer = ({ text }: { text: string }) => { - const parts = useTextWithRecordLinks(text); - - // If there are no record references, render normally - const hasRecordReferences = parts.some( - (part) => typeof part === 'object' && part.type === 'record', - ); - - if (!hasRecordReferences) { - return ( - }> - - {text} - - - ); - } - - // Render with record links inline return ( }> - {parts.map((part, index) => { - if (typeof part === 'string') { - return ( - - {part} - - ); - } - - if (part.type === 'record' && isDefined(part.props)) { - return ( - - - - ); - } - - return null; - })} + + {text} + ); }; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts index ae554682d9..fefbc8fcc3 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts @@ -40,6 +40,27 @@ export const generateFindToolInputSchema = ( filterSchema; }); + // Create the base filter schema with field-level filters + logical operators + // This matches the RecordGqlOperationFilter format used by the frontend + const filterSchema: z.ZodTypeAny = z.lazy(() => + z + .object({ + ...filterShape, + or: z + .array(filterSchema) + .optional() + .describe('OR condition - matches if ANY of the filters match'), + and: z + .array(filterSchema) + .optional() + .describe('AND condition - matches if ALL filters match'), + not: filterSchema + .optional() + .describe('NOT condition - matches if the filter does NOT match'), + }) + .partial(), + ); + return z.object({ loadingMessage: z .string() @@ -65,6 +86,17 @@ export const generateFindToolInputSchema = ( 'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.', ), ...filterShape, + or: z + .array(filterSchema) + .optional() + .describe('OR condition - matches if ANY of the filters match'), + and: z + .array(filterSchema) + .optional() + .describe('AND condition - matches if ALL filters match'), + not: filterSchema + .optional() + .describe('NOT condition - matches if the filter does NOT match'), }), }); }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts index 402facffcc..5ebd380de9 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts @@ -4,4 +4,5 @@ export enum ToolCategory { WORKFLOW = 'WORKFLOW', METADATA = 'METADATA', NATIVE_MODEL = 'NATIVE_MODEL', + VIEW = 'VIEW', } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts new file mode 100644 index 0000000000..aff410c0c8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { + type ToolProvider, + type ToolProviderContext, +} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; + +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory'; + +@Injectable() +export class ViewToolProvider implements ToolProvider { + readonly category = ToolCategory.VIEW; + + constructor( + private readonly viewToolsFactory: ViewToolsFactory, + private readonly permissionsService: PermissionsService, + ) {} + + async isAvailable(_context: ToolProviderContext): Promise { + return true; + } + + async generateTools(context: ToolProviderContext): Promise { + const workspaceMemberId = context.actorContext?.workspaceMemberId; + + const readTools = this.viewToolsFactory.generateReadTools( + context.workspaceId, + workspaceMemberId ?? undefined, + workspaceMemberId ?? undefined, + ); + + const hasViewPermission = + await this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.VIEWS, + ); + + if (hasViewPermission) { + const writeTools = this.viewToolsFactory.generateWriteTools( + context.workspaceId, + workspaceMemberId ?? undefined, + ); + + return { ...readTools, ...writeTools }; + } + + return readTools; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index de157a27e8..3389f5bc18 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -15,14 +15,14 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm export type ToolIndexEntry = { name: string; description: string; - category: 'database' | 'action' | 'workflow' | 'metadata'; + category: 'database' | 'action' | 'workflow' | 'metadata' | 'view'; objectName?: string; operation?: string; }; export type ToolSearchOptions = { limit?: number; - category?: 'database' | 'action' | 'workflow' | 'metadata'; + category?: 'database' | 'action' | 'workflow' | 'metadata' | 'view'; }; export type ToolContext = { @@ -173,6 +173,7 @@ export class ToolRegistryService { WORKFLOW: 'workflow', METADATA: 'metadata', NATIVE_MODEL: 'action', + VIEW: 'view', }; return Object.entries(tools).map(([name, tool]) => ({ diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 8f3caa4762..9997d78c77 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -6,6 +6,7 @@ import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/ import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider'; import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; +import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider'; import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; @@ -14,6 +15,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { ToolProviderService } from './services/tool-provider.service'; @@ -37,6 +39,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ObjectMetadataModule, FieldMetadataModule, PermissionsModule, + ViewModule, WorkspaceCacheModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, ], @@ -44,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ActionToolProvider, DatabaseToolProvider, MetadataToolProvider, + ViewToolProvider, WorkflowToolProvider, { provide: TOOL_PROVIDERS, @@ -51,17 +55,20 @@ import { ToolRegistryService } from './services/tool-registry.service'; actionProvider: ActionToolProvider, databaseProvider: DatabaseToolProvider, metadataProvider: MetadataToolProvider, + viewProvider: ViewToolProvider, workflowProvider: WorkflowToolProvider, ) => [ actionProvider, databaseProvider, metadataProvider, + viewProvider, workflowProvider, ], inject: [ ActionToolProvider, DatabaseToolProvider, MetadataToolProvider, + ViewToolProvider, WorkflowToolProvider, ], }, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts new file mode 100644 index 0000000000..3739c37a1d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts @@ -0,0 +1,28 @@ +import type { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; + +export const formatValidationErrors = ( + error: WorkspaceMigrationBuilderExceptionV2, +): string => { + const report = error.failedWorkspaceMigrationBuildResult.report; + const errorMessages: string[] = []; + + for (const [entityType, failures] of Object.entries(report)) { + if (Array.isArray(failures) && failures.length > 0) { + for (const failure of failures) { + if (failure.errors && Array.isArray(failure.errors)) { + for (const validationError of failure.errors) { + const message = validationError.message || validationError.code; + + errorMessages.push(`[${entityType}] ${message}`); + } + } + } + } + } + + if (errorMessages.length === 0) { + return error.message; + } + + return `Validation errors:\n${errorMessages.join('\n')}`; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index eee3d6bdf0..d724f929e5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -233,15 +233,17 @@ export class ChatExecutionService { viewName: string; filterDescriptions: string[]; }): string { - const { objectNameSingular, viewName, filterDescriptions } = + const { objectNameSingular, viewId, viewName, filterDescriptions } = browsingContext; - let context = `The user is viewing a list of ${objectNameSingular} records in a view called "${viewName}".`; + let context = `The user is viewing a list of ${objectNameSingular} records in a view called "${viewName}" (viewId: ${viewId}).`; if (filterDescriptions.length > 0) { context += `\nFilters applied: ${filterDescriptions.join(', ')}`; } + context += `\nUse get_view_query_parameters tool with this viewId to get the exact filter/sort parameters for querying records.`; + return context; } @@ -323,7 +325,13 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join(' ### Tool Catalog by Category`); - const categoryOrder = ['database', 'action', 'workflow', 'metadata']; + const categoryOrder = [ + 'database', + 'action', + 'workflow', + 'metadata', + 'view', + ]; for (const category of categoryOrder) { const tools = toolsByCategory.get(category); @@ -365,6 +373,8 @@ ${tools return 'Workflow Tools (create/manage workflows)'; case 'metadata': return 'Metadata Tools (schema management)'; + case 'view': + return 'View Tools (query views)'; default: return category; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts index 7231f6d4e2..2d42b22073 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts @@ -4,6 +4,7 @@ import { type ToolSet } from 'ai'; import { FieldMetadataType } from 'twenty-shared/types'; import { z } from 'zod'; +import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util'; import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; @@ -139,33 +140,6 @@ const DeleteFieldMetadataInputSchema = z.object({ }), }); -const formatValidationErrors = ( - error: WorkspaceMigrationBuilderExceptionV2, -): string => { - const report = error.failedWorkspaceMigrationBuildResult.report; - const errorMessages: string[] = []; - - for (const [entityType, failures] of Object.entries(report)) { - if (Array.isArray(failures) && failures.length > 0) { - for (const failure of failures) { - if (failure.errors && Array.isArray(failure.errors)) { - for (const validationError of failure.errors) { - const message = validationError.message || validationError.code; - - errorMessages.push(`[${entityType}] ${message}`); - } - } - } - } - } - - if (errorMessages.length === 0) { - return error.message; - } - - return `Validation errors:\n${errorMessages.join('\n')}`; -}; - @Injectable() export class FieldMetadataToolsFactory { constructor(private readonly fieldMetadataService: FieldMetadataService) {} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts index 57c801a347..03e6b4c41e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; import { z } from 'zod'; +import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; @@ -116,33 +117,6 @@ const DeleteObjectMetadataInputSchema = z.object({ }), }); -const formatValidationErrors = ( - error: WorkspaceMigrationBuilderExceptionV2, -): string => { - const report = error.failedWorkspaceMigrationBuildResult.report; - const errorMessages: string[] = []; - - for (const [entityType, failures] of Object.entries(report)) { - if (Array.isArray(failures) && failures.length > 0) { - for (const failure of failures) { - if (failure.errors && Array.isArray(failure.errors)) { - for (const validationError of failure.errors) { - const message = validationError.message || validationError.code; - - errorMessages.push(`[${entityType}] ${message}`); - } - } - } - } - } - - if (errorMessages.length === 0) { - return error.message; - } - - return `Validation errors:\n${errorMessages.join('\n')}`; -}; - @Injectable() export class ObjectMetadataToolsFactory { constructor(private readonly objectMetadataService: ObjectMetadataService) {} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/__tests__/view-query-params.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/__tests__/view-query-params.service.spec.ts new file mode 100644 index 0000000000..acea4ccd52 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/__tests__/view-query-params.service.spec.ts @@ -0,0 +1,254 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator'; +import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; +import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; +import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; + +describe('ViewQueryParamsService', () => { + let viewQueryParamsService: ViewQueryParamsService; + let viewService: jest.Mocked; + let flatEntityMapsCacheService: jest.Mocked; + + const mockWorkspaceId = 'workspace-id'; + const mockViewId = 'view-id'; + const mockObjectMetadataId = 'object-metadata-id'; + const mockFieldMetadataId = 'field-metadata-id'; + + const mockFlatObjectMetadataMaps = { + byId: { + [mockObjectMetadataId]: { + id: mockObjectMetadataId, + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + }, + }, + byNameSingular: {}, + byNamePlural: {}, + }; + + const mockFlatFieldMetadataMaps = { + byId: { + [mockFieldMetadataId]: { + id: mockFieldMetadataId, + name: 'name', + type: FieldMetadataType.TEXT, + label: 'Name', + options: null, + }, + }, + byNameAndObjectId: {}, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewQueryParamsService, + { + provide: ViewService, + useValue: { + findById: jest.fn(), + }, + }, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(), + }, + }, + ], + }).compile(); + + viewQueryParamsService = module.get( + ViewQueryParamsService, + ); + viewService = module.get(ViewService); + flatEntityMapsCacheService = module.get( + WorkspaceManyOrAllFlatEntityMapsCacheService, + ); + }); + + it('should be defined', () => { + expect(viewQueryParamsService).toBeDefined(); + }); + + describe('resolveViewToQueryParams', () => { + it('should throw error when view is not found', async () => { + viewService.findById.mockResolvedValue(null); + + await expect( + viewQueryParamsService.resolveViewToQueryParams( + mockViewId, + mockWorkspaceId, + ), + ).rejects.toThrow(`View with id ${mockViewId} not found`); + }); + + it('should return query params for a view without filters or sorts', async () => { + const mockView = { + id: mockViewId, + name: 'All Companies', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + visibility: ViewVisibility.WORKSPACE, + viewFilters: [], + viewFilterGroups: [], + viewSorts: [], + }; + + viewService.findById.mockResolvedValue(mockView as any); + flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: mockFlatObjectMetadataMaps, + flatFieldMetadataMaps: mockFlatFieldMetadataMaps, + } as any, + ); + + const result = await viewQueryParamsService.resolveViewToQueryParams( + mockViewId, + mockWorkspaceId, + ); + + expect(result.objectNameSingular).toBe('company'); + expect(result.filter).toEqual({}); + expect(result.orderBy).toEqual([]); + expect(result.viewName).toBe('All Companies'); + expect(result.viewType).toBe(ViewType.TABLE); + }); + + it('should return query params with filters', async () => { + const mockFilterGroupId = 'filter-group-id'; + const mockView = { + id: mockViewId, + name: 'Companies with Name', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + visibility: ViewVisibility.WORKSPACE, + viewFilters: [ + { + id: 'filter-id', + fieldMetadataId: mockFieldMetadataId, + operand: ViewFilterOperand.CONTAINS, + value: 'Acme', + viewFilterGroupId: mockFilterGroupId, + subFieldName: null, + }, + ], + viewFilterGroups: [ + { + id: mockFilterGroupId, + parentViewFilterGroupId: null, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }, + ], + viewSorts: [], + }; + + viewService.findById.mockResolvedValue(mockView as any); + flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: mockFlatObjectMetadataMaps, + flatFieldMetadataMaps: mockFlatFieldMetadataMaps, + } as any, + ); + + const result = await viewQueryParamsService.resolveViewToQueryParams( + mockViewId, + mockWorkspaceId, + ); + + expect(result.objectNameSingular).toBe('company'); + expect(result.viewName).toBe('Companies with Name'); + expect(result.filter).toBeDefined(); + }); + + it('should return query params with sorts', async () => { + const mockView = { + id: mockViewId, + name: 'Companies Sorted', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + visibility: ViewVisibility.WORKSPACE, + viewFilters: [], + viewFilterGroups: [], + viewSorts: [ + { + id: 'sort-id', + fieldMetadataId: mockFieldMetadataId, + direction: ViewSortDirection.DESC, + }, + ], + }; + + viewService.findById.mockResolvedValue(mockView as any); + flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: mockFlatObjectMetadataMaps, + flatFieldMetadataMaps: mockFlatFieldMetadataMaps, + } as any, + ); + + const result = await viewQueryParamsService.resolveViewToQueryParams( + mockViewId, + mockWorkspaceId, + ); + + expect(result.objectNameSingular).toBe('company'); + expect(result.orderBy).toHaveLength(1); + expect(result.orderBy[0]).toHaveProperty('name'); + }); + + it('should skip filters for deleted fields', async () => { + const deletedFieldId = 'deleted-field-id'; + const mockFilterGroupId = 'filter-group-id'; + const mockView = { + id: mockViewId, + name: 'View with deleted field filter', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + visibility: ViewVisibility.WORKSPACE, + viewFilters: [ + { + id: 'filter-id', + fieldMetadataId: deletedFieldId, + operand: ViewFilterOperand.CONTAINS, + value: 'test', + viewFilterGroupId: mockFilterGroupId, + subFieldName: null, + }, + ], + viewFilterGroups: [ + { + id: mockFilterGroupId, + parentViewFilterGroupId: null, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }, + ], + viewSorts: [], + }; + + viewService.findById.mockResolvedValue(mockView as any); + flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: mockFlatObjectMetadataMaps, + flatFieldMetadataMaps: mockFlatFieldMetadataMaps, + } as any, + ); + + const result = await viewQueryParamsService.resolveViewToQueryParams( + mockViewId, + mockWorkspaceId, + ); + + // Filter should be effectively empty because the field was deleted + expect(result.filter).toEqual({ and: [] }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts new file mode 100644 index 0000000000..bb5ce12719 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@nestjs/common'; + +import { + OrderByDirection, + RecordFilterGroupLogicalOperator, + type RecordGqlOperationFilter, +} from 'twenty-shared/types'; +import { + computeRecordGqlOperationFilter, + isDefined, + type RecordFilter, + type RecordFilterGroup, +} from 'twenty-shared/utils'; + +import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator'; +import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; + +export type ViewQueryParams = { + objectNameSingular: string; + filter: RecordGqlOperationFilter; + orderBy: ObjectRecordOrderBy; + viewName: string; + viewType: ViewType; +}; + +@Injectable() +export class ViewQueryParamsService { + constructor( + private readonly viewService: ViewService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + async resolveViewToQueryParams( + viewId: string, + workspaceId: string, + currentWorkspaceMemberId?: string, + ): Promise { + const view = await this.viewService.findById(viewId, workspaceId); + + if (!view) { + throw new Error(`View with id ${viewId} not found`); + } + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const objectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: view.objectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + const recordFilters: RecordFilter[] = (view.viewFilters ?? []) + .map((viewFilter) => { + const field = flatFieldMetadataMaps.byId[viewFilter.fieldMetadataId]; + + if (!field) return null; + + return { + id: viewFilter.id, + fieldMetadataId: viewFilter.fieldMetadataId, + value: viewFilter.value ?? '', + type: field.type, + recordFilterGroupId: viewFilter.viewFilterGroupId, + operand: viewFilter.operand, + subFieldName: viewFilter.subFieldName, + } as RecordFilter; + }) + .filter(isDefined); + + const recordFilterGroups: RecordFilterGroup[] = ( + view.viewFilterGroups ?? [] + ).map((group) => ({ + id: group.id, + parentRecordFilterGroupId: group.parentViewFilterGroupId, + logicalOperator: + group.logicalOperator === ViewFilterGroupLogicalOperator.OR + ? RecordFilterGroupLogicalOperator.OR + : RecordFilterGroupLogicalOperator.AND, + })); + + const fields = recordFilters + .map((filter) => { + const field = flatFieldMetadataMaps.byId[filter.fieldMetadataId]; + + if (!field) return null; + + return { + id: field.id, + name: field.name, + type: field.type, + label: field.label, + options: field.options?.map((opt) => ({ + id: opt.id ?? '', + label: opt.label, + value: opt.value, + color: 'color' in opt ? opt.color : undefined, + position: opt.position, + })), + }; + }) + .filter(isDefined); + + const filter = computeRecordGqlOperationFilter({ + fields, + recordFilters, + recordFilterGroups, + filterValueDependencies: { currentWorkspaceMemberId }, + }); + + const orderBy: ObjectRecordOrderBy = (view.viewSorts ?? []) + .map((sort) => { + const field = flatFieldMetadataMaps.byId[sort.fieldMetadataId]; + + if (!field) return null; + + return { + [field.name]: + sort.direction === ViewSortDirection.DESC + ? OrderByDirection.DescNullsLast + : OrderByDirection.AscNullsFirst, + }; + }) + .filter(isDefined); + + return { + objectNameSingular: objectMetadata.nameSingular, + filter, + orderBy, + viewName: view.name, + viewType: view.type, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts new file mode 100644 index 0000000000..12b338ca7b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts @@ -0,0 +1,431 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { OrderByDirection } from 'twenty-shared/types'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; +import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; +import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory'; + +describe('ViewToolsFactory', () => { + let viewToolsFactory: ViewToolsFactory; + let viewService: jest.Mocked; + let viewQueryParamsService: jest.Mocked; + let _flatEntityMapsCacheService: jest.Mocked; + + const mockWorkspaceId = 'workspace-id'; + const mockUserWorkspaceId = 'user-workspace-id'; + const mockViewId = 'view-id'; + const mockObjectMetadataId = 'object-metadata-id'; + const mockObjectNameSingular = 'company'; + + const mockView = { + id: mockViewId, + name: 'All Companies', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconBuilding', + visibility: ViewVisibility.WORKSPACE, + position: 0, + createdByUserWorkspaceId: mockUserWorkspaceId, + }; + + const mockFlatObjectMetadataMaps = { + byId: { + [mockObjectMetadataId]: { + id: mockObjectMetadataId, + nameSingular: mockObjectNameSingular, + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + }, + }, + idByUniversalIdentifier: {}, + universalIdentifiersByApplicationId: {}, + }; + + const callExecute = async (tool: any, input: any) => { + return tool.execute(input); + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewToolsFactory, + { + provide: ViewService, + useValue: { + findByWorkspaceId: jest.fn(), + findByObjectMetadataId: jest.fn(), + findById: jest.fn(), + createOne: jest.fn(), + updateOne: jest.fn(), + deleteOne: jest.fn(), + }, + }, + { + provide: ViewQueryParamsService, + useValue: { + resolveViewToQueryParams: jest.fn(), + }, + }, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ + flatObjectMetadataMaps: mockFlatObjectMetadataMaps, + }), + }, + }, + ], + }).compile(); + + viewToolsFactory = module.get(ViewToolsFactory); + viewService = module.get(ViewService); + viewQueryParamsService = module.get(ViewQueryParamsService); + _flatEntityMapsCacheService = module.get( + WorkspaceManyOrAllFlatEntityMapsCacheService, + ); + }); + + it('should be defined', () => { + expect(viewToolsFactory).toBeDefined(); + }); + + describe('generateReadTools', () => { + it('should generate get-views and get-view-query-parameters tools', () => { + const tools = viewToolsFactory.generateReadTools(mockWorkspaceId); + + expect(tools).toHaveProperty('get_views'); + expect(tools).toHaveProperty('get_view_query_parameters'); + expect(tools['get_views']).toHaveProperty('description'); + expect(tools['get_views']).toHaveProperty('inputSchema'); + expect(tools['get_views']).toHaveProperty('execute'); + expect(tools['get_view_query_parameters']).toHaveProperty('description'); + expect(tools['get_view_query_parameters']).toHaveProperty('inputSchema'); + expect(tools['get_view_query_parameters']).toHaveProperty('execute'); + }); + + describe('get_views tool', () => { + it('should return all views when no objectNameSingular filter', async () => { + const mockViews = [mockView]; + + viewService.findByWorkspaceId.mockResolvedValue(mockViews as any); + + const tools = viewToolsFactory.generateReadTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['get_views'], { + input: { limit: 50 }, + }); + + expect(viewService.findByWorkspaceId).toHaveBeenCalledWith( + mockWorkspaceId, + mockUserWorkspaceId, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: mockViewId, + name: 'All Companies', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconBuilding', + visibility: ViewVisibility.WORKSPACE, + position: 0, + }); + }); + + it('should filter views by objectNameSingular', async () => { + const mockViews = [mockView]; + + viewService.findByObjectMetadataId.mockResolvedValue(mockViews as any); + + const tools = viewToolsFactory.generateReadTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['get_views'], { + input: { objectNameSingular: mockObjectNameSingular, limit: 50 }, + }); + + expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith( + mockWorkspaceId, + mockObjectMetadataId, + mockUserWorkspaceId, + ); + expect(result).toHaveLength(1); + }); + + it('should respect limit parameter', async () => { + const mockViews = [ + { ...mockView, id: 'view-1' }, + { ...mockView, id: 'view-2' }, + { ...mockView, id: 'view-3' }, + ]; + + viewService.findByWorkspaceId.mockResolvedValue(mockViews as any); + + const tools = viewToolsFactory.generateReadTools(mockWorkspaceId); + + const result = await callExecute(tools['get_views'], { + input: { limit: 2 }, + }); + + expect(result).toHaveLength(2); + }); + }); + + describe('get-view-query-parameters tool', () => { + it('should return query parameters for a view', async () => { + const mockQueryParams = { + objectNameSingular: 'company', + filter: { name: { ilike: '%Acme%' } }, + orderBy: [{ name: OrderByDirection.AscNullsFirst }], + viewName: 'All Companies', + viewType: ViewType.TABLE, + }; + + viewQueryParamsService.resolveViewToQueryParams.mockResolvedValue( + mockQueryParams, + ); + + const tools = viewToolsFactory.generateReadTools( + mockWorkspaceId, + mockUserWorkspaceId, + 'workspace-member-id', + ); + + const result = await callExecute(tools['get_view_query_parameters'], { + input: { viewId: mockViewId }, + }); + + expect( + viewQueryParamsService.resolveViewToQueryParams, + ).toHaveBeenCalledWith( + mockViewId, + mockWorkspaceId, + 'workspace-member-id', + ); + expect(result).toEqual(mockQueryParams); + }); + }); + }); + + describe('generateWriteTools', () => { + it('should generate create-view, update-view, and delete-view tools', () => { + const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId); + + expect(tools).toHaveProperty('create_view'); + expect(tools).toHaveProperty('update_view'); + expect(tools).toHaveProperty('delete_view'); + }); + + describe('create_view tool', () => { + it('should create a new view', async () => { + const createdView = { + id: 'new-view-id', + name: 'New View', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + visibility: ViewVisibility.WORKSPACE, + }; + + viewService.createOne.mockResolvedValue(createdView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['create_view'], { + input: { + name: 'New View', + objectNameSingular: mockObjectNameSingular, + icon: 'IconTable', + }, + }); + + expect(viewService.createOne).toHaveBeenCalledWith({ + createViewInput: { + name: 'New View', + objectMetadataId: mockObjectMetadataId, + icon: 'IconTable', + type: ViewType.TABLE, + visibility: ViewVisibility.WORKSPACE, + }, + workspaceId: mockWorkspaceId, + createdByUserWorkspaceId: mockUserWorkspaceId, + }); + expect(result).toEqual({ + id: 'new-view-id', + name: 'New View', + objectNameSingular: mockObjectNameSingular, + type: ViewType.TABLE, + icon: 'IconTable', + visibility: ViewVisibility.WORKSPACE, + }); + }); + }); + + describe('update-view tool', () => { + it('should update a workspace view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.WORKSPACE, + }; + const updatedView = { + ...existingView, + name: 'Updated Name', + }; + + viewService.findById.mockResolvedValue(existingView as any); + viewService.updateOne.mockResolvedValue(updatedView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['update_view'], { + input: { + id: mockViewId, + name: 'Updated Name', + }, + }); + + expect(viewService.updateOne).toHaveBeenCalled(); + expect(result.name).toBe('Updated Name'); + }); + + it('should allow updating own unlisted view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.UNLISTED, + createdByUserWorkspaceId: mockUserWorkspaceId, + }; + const updatedView = { + ...existingView, + name: 'Updated Name', + }; + + viewService.findById.mockResolvedValue(existingView as any); + viewService.updateOne.mockResolvedValue(updatedView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['update_view'], { + input: { + id: mockViewId, + name: 'Updated Name', + }, + }); + + expect(result.name).toBe('Updated Name'); + }); + + it('should reject updating another users unlisted view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.UNLISTED, + createdByUserWorkspaceId: 'other-user-workspace-id', + }; + + viewService.findById.mockResolvedValue(existingView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await expect( + callExecute(tools['update_view'], { + input: { + id: mockViewId, + name: 'Updated Name', + }, + }), + ).rejects.toThrow('You can only update your own unlisted views'); + }); + + it('should throw error when view not found', async () => { + viewService.findById.mockResolvedValue(null); + + const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId); + + await expect( + callExecute(tools['update_view'], { + input: { + id: 'non-existent-id', + name: 'Updated Name', + }, + }), + ).rejects.toThrow('View with id non-existent-id not found'); + }); + }); + + describe('delete-view tool', () => { + it('should delete a workspace view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.WORKSPACE, + }; + const deletedView = { + id: mockViewId, + name: 'All Companies', + }; + + viewService.findById.mockResolvedValue(existingView as any); + viewService.deleteOne.mockResolvedValue(deletedView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['delete_view'], { + input: { id: mockViewId }, + }); + + expect(viewService.deleteOne).toHaveBeenCalledWith({ + deleteViewInput: { id: mockViewId }, + workspaceId: mockWorkspaceId, + }); + expect(result).toEqual({ + id: mockViewId, + name: 'All Companies', + deleted: true, + }); + }); + + it('should reject deleting another users unlisted view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.UNLISTED, + createdByUserWorkspaceId: 'other-user-workspace-id', + }; + + viewService.findById.mockResolvedValue(existingView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await expect( + callExecute(tools['delete_view'], { + input: { id: mockViewId }, + }), + ).rejects.toThrow('You can only delete your own unlisted views'); + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts new file mode 100644 index 0000000000..54e9d9eee1 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts @@ -0,0 +1,340 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { z } from 'zod'; + +import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; +import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; +import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; + +const GetViewsInputSchema = z.object({ + loadingMessage: z + .string() + .optional() + .describe('A clear description of the action being performed.'), + input: z.object({ + objectNameSingular: z + .string() + .optional() + .describe( + 'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.', + ), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(50) + .describe('Maximum views to return.'), + }), +}); + +const GetViewQueryParamsInputSchema = z.object({ + loadingMessage: z + .string() + .optional() + .describe('A clear description of the action being performed.'), + input: z.object({ + viewId: z + .string() + .uuid() + .describe('ID of the view to get query parameters for.'), + }), +}); + +const CreateViewInputSchema = z.object({ + loadingMessage: z + .string() + .optional() + .describe('A clear description of the action being performed.'), + input: z.object({ + name: z.string().describe('View name'), + objectNameSingular: z + .string() + .describe( + 'Object name this view is for (e.g., "task", "person", "company")', + ), + icon: z + .string() + .optional() + .default('IconList') + .describe('Icon identifier (e.g., "IconList", "IconCheckbox")'), + type: z + .enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR]) + .optional() + .default(ViewType.TABLE) + .describe('View type'), + visibility: z + .enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED]) + .optional() + .default(ViewVisibility.WORKSPACE) + .describe('View visibility'), + }), +}); + +const UpdateViewInputSchema = z.object({ + loadingMessage: z + .string() + .optional() + .describe('A clear description of the action being performed.'), + input: z.object({ + id: z.string().uuid().describe('View ID to update'), + name: z.string().optional().describe('New view name'), + icon: z.string().optional().describe('New icon identifier'), + }), +}); + +const DeleteViewInputSchema = z.object({ + loadingMessage: z + .string() + .optional() + .describe('A clear description of the action being performed.'), + input: z.object({ + id: z.string().uuid().describe('View ID to delete'), + }), +}); + +@Injectable() +export class ViewToolsFactory { + constructor( + private readonly viewService: ViewService, + private readonly viewQueryParamsService: ViewQueryParamsService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + private async resolveObjectMetadataId( + workspaceId: string, + objectNameSingular: string, + ): Promise { + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const objectMetadata = Object.values(flatObjectMetadataMaps.byId).find( + (obj) => obj?.nameSingular === objectNameSingular, + ); + + if (!objectMetadata) { + throw new Error( + `Object "${objectNameSingular}" not found. Use get_object_metadata to list available objects.`, + ); + } + + return objectMetadata.id; + } + + generateReadTools( + workspaceId: string, + userWorkspaceId?: string, + currentWorkspaceMemberId?: string, + ): ToolSet { + return { + get_views: { + description: + 'List views in the workspace. Views define how records are displayed, filtered, and sorted.', + inputSchema: GetViewsInputSchema, + execute: async (parameters: { + input: { objectNameSingular?: string; limit?: number }; + }) => { + let views; + + if (parameters.input.objectNameSingular) { + const objectMetadataId = await this.resolveObjectMetadataId( + workspaceId, + parameters.input.objectNameSingular, + ); + + views = await this.viewService.findByObjectMetadataId( + workspaceId, + objectMetadataId, + userWorkspaceId, + ); + } else { + views = await this.viewService.findByWorkspaceId( + workspaceId, + userWorkspaceId, + ); + } + + const limitedViews = views.slice(0, parameters.input.limit ?? 50); + + return limitedViews.map((view) => ({ + id: view.id, + name: view.name, + objectMetadataId: view.objectMetadataId, + type: view.type, + icon: view.icon, + visibility: view.visibility, + position: view.position, + })); + }, + }, + get_view_query_parameters: { + description: + 'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.', + inputSchema: GetViewQueryParamsInputSchema, + execute: async (parameters: { input: { viewId: string } }) => { + return this.viewQueryParamsService.resolveViewToQueryParams( + parameters.input.viewId, + workspaceId, + currentWorkspaceMemberId, + ); + }, + }, + }; + } + + generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet { + return { + create_view: { + description: + 'Create a new view for an object. Views define how records are displayed.', + inputSchema: CreateViewInputSchema, + execute: async (parameters: { + input: { + name: string; + objectNameSingular: string; + icon?: string; + type?: ViewType; + visibility?: ViewVisibility; + }; + }) => { + try { + const objectMetadataId = await this.resolveObjectMetadataId( + workspaceId, + parameters.input.objectNameSingular, + ); + + const view = await this.viewService.createOne({ + createViewInput: { + name: parameters.input.name, + objectMetadataId, + icon: parameters.input.icon ?? 'IconList', + type: parameters.input.type ?? ViewType.TABLE, + visibility: + parameters.input.visibility ?? ViewVisibility.WORKSPACE, + }, + workspaceId, + createdByUserWorkspaceId: userWorkspaceId, + }); + + return { + id: view.id, + name: view.name, + objectNameSingular: parameters.input.objectNameSingular, + type: view.type, + icon: view.icon, + visibility: view.visibility, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderExceptionV2) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + update_view: { + description: + 'Update an existing view. You can change the name and icon.', + inputSchema: UpdateViewInputSchema, + execute: async (parameters: { + input: { + id: string; + name?: string; + icon?: string; + }; + }) => { + try { + const existingView = await this.viewService.findById( + parameters.input.id, + workspaceId, + ); + + if (!existingView) { + throw new Error(`View with id ${parameters.input.id} not found`); + } + + if ( + existingView.visibility === ViewVisibility.UNLISTED && + existingView.createdByUserWorkspaceId !== userWorkspaceId + ) { + throw new Error('You can only update your own unlisted views'); + } + + const view = await this.viewService.updateOne({ + updateViewInput: { + id: parameters.input.id, + name: parameters.input.name, + icon: parameters.input.icon, + }, + workspaceId, + userWorkspaceId, + }); + + return { + id: view.id, + name: view.name, + objectMetadataId: view.objectMetadataId, + type: view.type, + icon: view.icon, + visibility: view.visibility, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderExceptionV2) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + delete_view: { + description: 'Delete a view by its ID.', + inputSchema: DeleteViewInputSchema, + execute: async (parameters: { input: { id: string } }) => { + try { + const existingView = await this.viewService.findById( + parameters.input.id, + workspaceId, + ); + + if (!existingView) { + throw new Error(`View with id ${parameters.input.id} not found`); + } + + if ( + existingView.visibility === ViewVisibility.UNLISTED && + existingView.createdByUserWorkspaceId !== userWorkspaceId + ) { + throw new Error('You can only delete your own unlisted views'); + } + + const view = await this.viewService.deleteOne({ + deleteViewInput: { id: parameters.input.id }, + workspaceId, + }); + + return { + id: view.id, + name: view.name, + deleted: true, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderExceptionV2) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts index 87fc538750..a98b6256a0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts @@ -16,7 +16,9 @@ import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort. import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewResolver } from 'src/engine/metadata-modules/view/resolvers/view.resolver'; +import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; @@ -39,7 +41,17 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa WorkspaceManyOrAllFlatEntityMapsCacheModule, ], controllers: [ViewController], - providers: [ViewService, ViewResolver], - exports: [ViewService, TypeOrmModule.forFeature([ViewEntity])], + providers: [ + ViewService, + ViewResolver, + ViewQueryParamsService, + ViewToolsFactory, + ], + exports: [ + ViewService, + ViewQueryParamsService, + ViewToolsFactory, + TypeOrmModule.forFeature([ViewEntity]), + ], }) export class ViewModule {}