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 () => {
),
+ 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 {}