diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts new file mode 100644 index 0000000000..3cac0557c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts @@ -0,0 +1,72 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +const fieldOfType = ( + type: FieldMetadataType, + name = 'body', + settings?: Record, +) => ({ type, name, settings }) as FieldMetadataEntity; + +describe('generateFieldFilterZodSchema', () => { + describe('TEXT', () => { + it('exposes scalar pattern operators at the field root', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.TEXT), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ ilike: '%foo%' })).toEqual({ ilike: '%foo%' }); + }); + }); + + describe('RICH_TEXT', () => { + // Regression for the AI find-records bug: RICH_TEXT is a composite + // (`markdown` / `blocknote` sub-fields). Advertising root-level scalar + // operators made the agent emit `{ ilike }`, which the query layer rejects + // with `Sub field "ilike" not found for composite type: RICH_TEXT`. + it('routes pattern operators onto the markdown sub-field', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.RICH_TEXT), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ markdown: { ilike: '%hello%' } })).toEqual({ + markdown: { ilike: '%hello%' }, + }); + }); + + it('no longer accepts scalar operators at the composite root', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.RICH_TEXT), + ); + + // Root-level operators are stripped (unknown keys), so the malformed + // `{ ilike }` filter never reaches the query runner. + expect(schema!.parse({ ilike: '%hello%' })).toEqual({}); + }); + }); + + describe('MORPH_RELATION', () => { + // Regression: morph relations (e.g. noteTarget.targetPerson) are filtered + // by their join column (`${name}Id`). Without a dedicated case they hit the + // text default and advertise like/ilike, which the runner rejects when it + // resolves the relation (`Object person doesn't have any "ilike" field`). + const uuid = '7def8b6a-ec89-48f1-9835-ec2f7c726ef0'; + + it('filters by the related record id and rejects text operators', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.MORPH_RELATION, 'targetPerson', { + relationType: RelationType.MANY_TO_ONE, + }), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ eq: uuid })).toEqual({ eq: uuid }); + // `ilike` is not a valid operator for a relation id — stripped. + expect(schema!.parse({ ilike: '%Tom%' })).toEqual({}); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts index 80bc9fb486..2591aa65fe 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts @@ -3,9 +3,6 @@ import { z } from 'zod'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { AddressFilterSchema, ArrayFieldFilterSchema, @@ -19,9 +16,13 @@ import { NullCheckEnum, NumberFilterSchema, PhonesFilterSchema, + RichTextFilterSchema, TextFilterSchema, UuidFilterSchema, } from 'src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; export { NullCheckEnum }; @@ -33,9 +34,11 @@ export const generateFieldFilterZodSchema = ( return UuidFilterSchema; case FieldMetadataType.TEXT: - case FieldMetadataType.RICH_TEXT: return TextFilterSchema; + case FieldMetadataType.RICH_TEXT: + return RichTextFilterSchema; + case FieldMetadataType.NUMBER: case FieldMetadataType.NUMERIC: case FieldMetadataType.POSITION: @@ -130,9 +133,14 @@ export const generateFieldFilterZodSchema = ( case FieldMetadataType.LINKS: return LinksFilterSchema; + case FieldMetadataType.MORPH_RELATION: case FieldMetadataType.RELATION: if ( - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataEntityOfType( + field, + FieldMetadataType.MORPH_RELATION, + )) && field.settings?.relationType === RelationType.MANY_TO_ONE ) { return UuidFilterSchema; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts index b23fb72a9c..3d8b8a828d 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts @@ -48,7 +48,8 @@ export const generateRecordFilterSchema = ({ } const isManyToOneRelationField = - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) && field.settings?.relationType === RelationType.MANY_TO_ONE; filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts index f3c13d1799..4a0522776b 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts @@ -217,6 +217,25 @@ export const PhonesFilterSchema = z }) .optional(); +export const RichTextFilterSchema = z + .object({ + markdown: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + startsWith: z.string().optional().describe('Starts with'), + endsWith: z.string().optional().describe('Ends with'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + export const CurrencyFilterSchema = z .object({ amountMicros: z