diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index d50492535..113b69ccf 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -14,6 +14,7 @@ export type Scalars = { Int: number; Float: number; ConnectionCursor: any; + Date: any; DateTime: string; JSON: any; JSONObject: any; @@ -356,6 +357,17 @@ export type CustomDomainValidRecords = { records: Array; }; +export type DateFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; +}; + export type DeleteApprovedAccessDomainInput = { id: Scalars['String']; }; @@ -579,6 +591,11 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } +export enum FilterIs { + NotNull = 'NotNull', + Null = 'Null' +} + export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['String']; @@ -631,6 +648,17 @@ export enum HealthIndicatorId { worker = 'worker' } +export type IdFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; +}; + export enum IdentityProviderType { OIDC = 'OIDC', SAML = 'SAML' @@ -1205,6 +1233,16 @@ export type ObjectIndexMetadatasConnection = { pageInfo: PageInfo; }; +export type ObjectRecordFilterInput = { + and?: InputMaybe>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>; + updatedAt?: InputMaybe; +}; + /** Onboarding status */ export enum OnboardingStatus { COMPLETED = 'COMPLETED', @@ -1396,6 +1434,8 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = { export type QueryGlobalSearchArgs = { excludedObjectNameSingulars?: InputMaybe>; + filter?: InputMaybe; + includedObjectNameSingulars?: InputMaybe>; limit: Scalars['Int']; searchInput: Scalars['String']; }; diff --git a/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts index e94b5a62d..ac652feea 100644 --- a/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts @@ -20,10 +20,11 @@ describe('GlobalSearchService', () => { describe('filterObjectMetadataItems', () => { it('should return searchable object metadata items', () => { - const objectMetadataItems = service.filterObjectMetadataItems( - mockObjectMetadataItemsWithFieldMaps, - [], - ); + const objectMetadataItems = service.filterObjectMetadataItems({ + objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps, + includedObjectNameSingulars: [], + excludedObjectNameSingulars: [], + }); expect(objectMetadataItems).toEqual([ mockObjectMetadataItemsWithFieldMaps[0], @@ -32,16 +33,28 @@ describe('GlobalSearchService', () => { ]); }); it('should return searchable object metadata items without excluded ones', () => { - const objectMetadataItems = service.filterObjectMetadataItems( - mockObjectMetadataItemsWithFieldMaps, - ['company'], - ); + const objectMetadataItems = service.filterObjectMetadataItems({ + objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps, + includedObjectNameSingulars: [], + excludedObjectNameSingulars: ['company'], + }); expect(objectMetadataItems).toEqual([ mockObjectMetadataItemsWithFieldMaps[0], mockObjectMetadataItemsWithFieldMaps[2], ]); }); + it('should return searchable object metadata items with included ones only', () => { + const objectMetadataItems = service.filterObjectMetadataItems({ + objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps, + includedObjectNameSingulars: ['company'], + excludedObjectNameSingulars: [], + }); + + expect(objectMetadataItems).toEqual([ + mockObjectMetadataItemsWithFieldMaps[1], + ]); + }); }); describe('getLabelIdentifierColumns', () => { diff --git a/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts index 3f03c32c7..bcb2fd667 100644 --- a/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts +++ b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts @@ -2,6 +2,8 @@ import { ArgsType, Field, Int } from '@nestjs/graphql'; import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; +import { ObjectRecordFilterInput } from 'src/engine/core-modules/global-search/dtos/object-record-filter-input'; + @ArgsType() export class GlobalSearchArgs { @Field(() => String) @@ -12,6 +14,15 @@ export class GlobalSearchArgs { @IsInt() limit: number; + @IsArray() + @Field(() => [String], { nullable: true }) + @IsOptional() + includedObjectNameSingulars?: string[]; + + @IsOptional() + @Field(() => ObjectRecordFilterInput, { nullable: true }) + filter?: ObjectRecordFilterInput; + @IsArray() @Field(() => [String], { nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/global-search/dtos/object-record-filter-input.ts b/packages/twenty-server/src/engine/core-modules/global-search/dtos/object-record-filter-input.ts new file mode 100644 index 000000000..54ec70471 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/dtos/object-record-filter-input.ts @@ -0,0 +1,116 @@ +import { Field, ID, InputType, registerEnumType } from '@nestjs/graphql'; + +import { IsArray, IsOptional } from 'class-validator'; + +import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { DateScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class ObjectRecordFilterInput implements Partial { + @Field(() => [ObjectRecordFilterInput], { nullable: true }) + @IsOptional() + @IsArray() + and?: ObjectRecordFilterInput[]; + + @Field(() => ObjectRecordFilterInput, { nullable: true }) + @IsOptional() + not?: ObjectRecordFilterInput; + + @Field(() => [ObjectRecordFilterInput], { nullable: true }) + @IsOptional() + @IsArray() + or?: ObjectRecordFilterInput[]; + + @Field(() => IDFilterType, { nullable: true }) + @IsOptional() + id?: IDFilterType | null; + + @Field(() => DateFilterType, { nullable: true }) + createdAt?: DateFilterType | null; + + @Field(() => DateFilterType, { nullable: true }) + updatedAt?: DateFilterType | null; + + @Field(() => DateFilterType, { nullable: true }) + deletedAt?: DateFilterType | null; +} + +@InputType('IDFilter') +class IDFilterType { + @Field(() => ID, { nullable: true }) + @IsOptional() + eq?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + gt?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + gte?: string; + + @Field(() => [ID], { nullable: true }) + @IsOptional() + in?: string[]; + + @Field(() => ID, { nullable: true }) + @IsOptional() + lt?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + lte?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + neq?: string; + + @Field(() => FilterIs, { nullable: true }) + @IsOptional() + is?: FilterIs; +} + +@InputType('DateFilter') +class DateFilterType { + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + eq?: Date; + + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + gt?: Date; + + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + gte?: Date; + + @Field(() => [DateScalarType], { nullable: true }) + @IsOptional() + in?: Date[]; + + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + lt?: Date; + + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + lte?: Date; + + @Field(() => DateScalarType, { nullable: true }) + @IsOptional() + neq?: Date; + + @Field(() => FilterIs, { nullable: true }) + @IsOptional() + is?: FilterIs; +} + +enum FilterIs { + NotNull = 'NOT_NULL', + Null = 'NULL', +} + +registerEnumType(FilterIs, { + name: 'FilterIs', +}); diff --git a/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts b/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts index 0fe79a916..06b805e72 100644 --- a/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts +++ b/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { GlobalSearchResolver } from 'src/engine/core-modules/global-search/global-search.resolver'; import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @Module({ - imports: [WorkspaceCacheStorageModule], + imports: [WorkspaceCacheStorageModule, FeatureFlagModule], providers: [GlobalSearchResolver, GlobalSearchService], }) export class GlobalSearchModule {} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts b/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts index 5a7039c85..1b8bd8ab9 100644 --- a/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts @@ -3,6 +3,9 @@ import { Args, Query, Resolver } from '@nestjs/graphql'; import chunk from 'lodash.chunk'; +import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { GlobalSearchArgs } from 'src/engine/core-modules/global-search/dtos/global-search-args'; import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto'; import { @@ -27,13 +30,20 @@ export class GlobalSearchResolver { private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly twentyORMManager: TwentyORMManager, private readonly globalSearchService: GlobalSearchService, + private readonly featureFlagService: FeatureFlagService, ) {} @Query(() => [GlobalSearchRecordDTO]) async globalSearch( @AuthWorkspace() workspace: Workspace, @Args() - { searchInput, limit, excludedObjectNameSingulars }: GlobalSearchArgs, + { + searchInput, + limit, + filter, + includedObjectNameSingulars, + excludedObjectNameSingulars, + }: GlobalSearchArgs, ) { const currentCacheVersion = await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); @@ -58,15 +68,19 @@ export class GlobalSearchResolver { ); } + const featureFlagMap = + await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id); + const objectMetadataItemWithFieldMaps = Object.values( objectMetadataMaps.byId, ); const filteredObjectMetadataItems = - this.globalSearchService.filterObjectMetadataItems( + this.globalSearchService.filterObjectMetadataItems({ objectMetadataItemWithFieldMaps, - excludedObjectNameSingulars, - ); + includedObjectNameSingulars: includedObjectNameSingulars ?? [], + excludedObjectNameSingulars: excludedObjectNameSingulars ?? [], + }); const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] = []; @@ -83,18 +97,18 @@ export class GlobalSearchResolver { objectMetadataItem.nameSingular, ); - repository.createQueryBuilder(); - return { objectMetadataItem, records: - await this.globalSearchService.buildSearchQueryAndGetRecords( - repository, + await this.globalSearchService.buildSearchQueryAndGetRecords({ + entityManager: repository, objectMetadataItem, - formatSearchTerms(searchInput, 'and'), - formatSearchTerms(searchInput, 'or'), + featureFlagMap, + searchTerms: formatSearchTerms(searchInput, 'and'), + searchTermsOr: formatSearchTerms(searchInput, 'or'), limit, - ), + filter: filter ?? ({} as ObjectRecordFilter), + }), }; }), ); diff --git a/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts b/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts index 18e36bcf1..ddac77087 100644 --- a/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts @@ -1,12 +1,16 @@ -import { Entity } from '@microsoft/microsoft-graph-types'; +import { Injectable } from '@nestjs/common'; + import { FieldMetadataType, getLogoUrlFromDomainName } from 'twenty-shared'; -import { Brackets } from 'typeorm'; +import { Brackets, ObjectLiteral } from 'typeorm'; import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS } from 'src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms'; import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank'; import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto'; +import { ObjectRecordFilterInput } from 'src/engine/core-modules/global-search/dtos/object-record-filter-input'; import { GlobalSearchException, GlobalSearchExceptionCode, @@ -14,30 +18,70 @@ import { import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item'; import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +@Injectable() export class GlobalSearchService { - filterObjectMetadataItems( - objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[], - excludedObjectNameSingulars: string[] | undefined, - ) { + filterObjectMetadataItems({ + objectMetadataItemWithFieldMaps, + includedObjectNameSingulars, + excludedObjectNameSingulars, + }: { + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[]; + includedObjectNameSingulars: string[]; + excludedObjectNameSingulars: string[]; + }) { return objectMetadataItemWithFieldMaps.filter( ({ nameSingular, isSearchable }) => { - return ( - !excludedObjectNameSingulars?.includes(nameSingular) && isSearchable - ); + if (!isSearchable) { + return false; + } + if (excludedObjectNameSingulars.includes(nameSingular)) { + return false; + } + if (includedObjectNameSingulars.length > 0) { + return includedObjectNameSingulars.includes(nameSingular); + } + + return true; }, ); } - async buildSearchQueryAndGetRecords( - entityManager: WorkspaceRepository, - objectMetadataItem: ObjectMetadataItemWithFieldMaps, - searchTerms: string, - searchTermsOr: string, - limit: number, - ) { + async buildSearchQueryAndGetRecords({ + entityManager, + objectMetadataItem, + featureFlagMap, + searchTerms, + searchTermsOr, + limit, + filter, + }: { + entityManager: WorkspaceRepository; + objectMetadataItem: ObjectMetadataItemWithFieldMaps; + featureFlagMap: FeatureFlagMap; + searchTerms: string; + searchTermsOr: string; + limit: number; + filter: ObjectRecordFilterInput; + }) { const queryBuilder = entityManager.createQueryBuilder(); + + const queryParser = new GraphqlQueryParser( + objectMetadataItem.fieldsByName, + generateObjectMetadataMaps([objectMetadataItem]), + featureFlagMap, + ); + + queryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataItem.nameSingular, + filter, + ); + + queryParser.applyDeletedAtToBuilder(queryBuilder, filter); + const imageIdentifierField = this.getImageIdentifierColumn(objectMetadataItem);