diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts index 3021f9d1b..85e91f98e 100644 --- a/packages/twenty-server/src/app.module.ts +++ b/packages/twenty-server/src/app.module.ts @@ -37,6 +37,7 @@ const MIGRATED_REST_METHODS = [ RequestMethod.POST, RequestMethod.PATCH, RequestMethod.PUT, + RequestMethod.GET, ]; @Module({ diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts index 36fa98e7e..2b7949613 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts @@ -6,7 +6,6 @@ import { } from 'typeorm'; import { ObjectRecordFilter } 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 { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; @@ -16,14 +15,10 @@ export class GraphqlQueryFilterConditionParser { private fieldMetadataMapByName: FieldMetadataMap; private queryFilterFieldParser: GraphqlQueryFilterFieldParser; - constructor( - fieldMetadataMapByName: FieldMetadataMap, - featureFlagsMap: FeatureFlagMap, - ) { + constructor(fieldMetadataMapByName: FieldMetadataMap) { this.fieldMetadataMapByName = fieldMetadataMapByName; this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser( this.fieldMetadataMapByName, - featureFlagsMap, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 5eb4b6940..e013fc794 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -1,7 +1,6 @@ import { WhereExpressionBuilder } from 'typeorm'; import { capitalize } from 'twenty-shared/utils'; -import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { @@ -18,14 +17,9 @@ const ARRAY_OPERATORS = ['in', 'contains', 'notContains']; export class GraphqlQueryFilterFieldParser { private fieldMetadataMapByName: FieldMetadataMap; - private featureFlagsMap: FeatureFlagMap; - constructor( - fieldMetadataMapByName: FieldMetadataMap, - featureFlagsMap: FeatureFlagMap, - ) { + constructor(fieldMetadataMapByName: FieldMetadataMap) { this.fieldMetadataMapByName = fieldMetadataMapByName; - this.featureFlagsMap = featureFlagsMap; } public parse( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index 40eda0e71..a19fa7fab 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -4,7 +4,6 @@ import { ObjectRecordOrderBy, OrderByDirection, } 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 { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { @@ -18,14 +17,9 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac export class GraphqlQueryOrderFieldParser { private fieldMetadataMapByName: FieldMetadataMap; - private featureFlagsMap: FeatureFlagMap; - constructor( - fieldMetadataMapByName: FieldMetadataMap, - featureFlagsMap: FeatureFlagMap, - ) { + constructor(fieldMetadataMapByName: FieldMetadataMap) { this.fieldMetadataMapByName = fieldMetadataMapByName; - this.featureFlagsMap = featureFlagsMap; } parse( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index f15acd743..7cbcb307d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -39,11 +39,9 @@ export class GraphqlQueryParser { this.featureFlagsMap = featureFlagsMap; this.filterConditionParser = new GraphqlQueryFilterConditionParser( this.fieldMetadataMapByName, - featureFlagsMap, ); this.orderFieldParser = new GraphqlQueryOrderFieldParser( this.fieldMetadataMapByName, - featureFlagsMap, ); } diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts index b06928937..0258ca9a8 100644 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts @@ -22,6 +22,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Controller('rest/*') @UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(RestApiExceptionFilter) export class RestApiCoreController { constructor( private readonly restApiCoreService: RestApiCoreService, @@ -29,7 +30,6 @@ export class RestApiCoreController { ) {} @Post('/duplicates') - @UseFilters(RestApiExceptionFilter) async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreService.findDuplicates(request); @@ -37,17 +37,13 @@ export class RestApiCoreController { } @Get() - @UseFilters(RestApiExceptionFilter) async handleApiGet(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiCoreService.get(request); + const result = await this.restApiCoreServiceV2.get(request); - res.status(200).send(cleanGraphQLResponse(result.data.data)); + res.status(200).send(result); } @Delete() - // We should move this exception filter to RestApiCoreController class level - // when all endpoints are migrated to v2 - @UseFilters(RestApiExceptionFilter) async handleApiDelete(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.delete(request); @@ -55,7 +51,6 @@ export class RestApiCoreController { } @Post() - @UseFilters(RestApiExceptionFilter) async handleApiPost(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.createOne(request); @@ -74,7 +69,6 @@ export class RestApiCoreController { // We keep it to avoid a breaking change since it initially used PUT instead of PATCH, // and because the PUT verb is often used as a PATCH. @Put() - @UseFilters(RestApiExceptionFilter) async handleApiPut(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.update(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts index c6ab73246..6604f557e 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts @@ -2,16 +2,52 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { Request } from 'express'; import { capitalize } from 'twenty-shared/utils'; +import { ObjectLiteral, OrderByCondition, SelectQueryBuilder } from 'typeorm'; +import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; +import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; +import { FieldValue } from 'src/engine/api/rest/core/types/field-value.type'; +import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory'; +import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory'; +import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory'; +import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory'; +import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util'; +interface FindManyMeta { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + totalCount: number; +} + +interface FormatResultParams { + operation: 'delete' | 'create' | 'update' | 'findOne' | 'findMany'; + objectNameSingular?: string; + objectNamePlural?: string; + data: T; + meta?: FindManyMeta; +} @Injectable() export class RestApiCoreServiceV2 { constructor( private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly limitInputFactory: LimitInputFactory, + private readonly filterInputFactory: FilterInputFactory, + private readonly featureFlagService: FeatureFlagService, + private readonly orderByInputFactory: OrderByInputFactory, + private readonly startingAfterInputFactory: StartingAfterInputFactory, + private readonly endingBeforeInputFactory: EndingBeforeInputFactory, ) {} async delete(request: Request) { @@ -29,8 +65,12 @@ export class RestApiCoreServiceV2 { await repository.delete(recordId); - return this.formatResult('delete', objectMetadataNameSingular, { - id: recordToDelete.id, + return this.formatResult({ + operation: 'delete', + objectNameSingular: objectMetadataNameSingular, + data: { + id: recordToDelete.id, + }, }); } @@ -41,11 +81,11 @@ export class RestApiCoreServiceV2 { await this.getRepositoryAndMetadataOrFail(request); const createdRecord = await repository.save(body); - return this.formatResult( - 'create', - objectMetadataNameSingular, - createdRecord, - ); + return this.formatResult({ + operation: 'create', + objectNameSingular: objectMetadataNameSingular, + data: createdRecord, + }); } async update(request: Request) { @@ -67,24 +107,310 @@ export class RestApiCoreServiceV2 { ...request.body, }); - return this.formatResult( - 'update', + return this.formatResult({ + operation: 'update', + objectNameSingular: objectMetadataNameSingular, + data: updatedRecord, + }); + } + + async get(request: Request) { + const { id: recordId } = parseCorePath(request); + const { objectMetadataNameSingular, - updatedRecord, + repository, + objectMetadata, + objectMetadataItemWithFieldsMaps, + } = await this.getRepositoryAndMetadataOrFail(request); + + if (recordId) { + return await this.findOne( + repository, + recordId, + objectMetadataNameSingular, + ); + } else { + return await this.findMany( + request, + repository, + objectMetadata, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + ); + } + } + + private async findOne( + repository: any, + recordId: string, + objectMetadataNameSingular: string, + ) { + const record = await repository.findOne({ + where: { id: recordId }, + }); + + return this.formatResult({ + operation: 'findOne', + objectNameSingular: objectMetadataNameSingular, + data: record, + }); + } + + private async findMany( + request: Request, + repository: WorkspaceRepository, + objectMetadata: any, + objectMetadataNameSingular: string, + objectMetadataItemWithFieldsMaps: + | ObjectMetadataItemWithFieldMaps + | undefined, + ) { + // Get input parameters + const inputs = this.getPaginationInputs(request, objectMetadata); + + // Create query builder + const qb = repository.createQueryBuilder(objectMetadataNameSingular); + + // Apply filters with cursor + const { finalQuery } = await this.applyFiltersWithCursor( + qb, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + inputs, + ); + + // Get total count + const totalCount = await this.getTotalCount(finalQuery); + + // Get records with pagination + const { finalRecords, hasMoreRecords } = + await this.getRecordsWithPagination( + finalQuery, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + inputs, + ); + + // Format and return result + return this.formatPaginatedResult( + finalRecords, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + objectMetadata, + inputs.isForwardPagination, + hasMoreRecords, + totalCount, ); } - private formatResult( - operation: 'delete' | 'create' | 'update' | 'find', - objectNameSingular: string, - data: T, + private getPaginationInputs(request: Request, objectMetadata: any) { + const limit = this.limitInputFactory.create(request); + const filter = this.filterInputFactory.create(request, objectMetadata); + const orderBy = this.orderByInputFactory.create(request, objectMetadata); + const endingBefore = this.endingBeforeInputFactory.create(request); + const startingAfter = this.startingAfterInputFactory.create(request); + const isForwardPagination = !endingBefore; + + return { + limit, + filter, + orderBy, + endingBefore, + startingAfter, + isForwardPagination, + }; + } + + private async applyFiltersWithCursor( + qb: SelectQueryBuilder, + objectMetadataNameSingular: string, + objectMetadataItemWithFieldsMaps: + | ObjectMetadataItemWithFieldMaps + | undefined, + inputs: { + filter: Record; + orderBy: any; + startingAfter: string | undefined; + endingBefore: string | undefined; + isForwardPagination: boolean; + }, ) { + const fieldMetadataMapByName = + objectMetadataItemWithFieldsMaps?.fieldsByName || {}; + + let appliedFilters = inputs.filter; + + // Handle cursor-based filtering + if (inputs.startingAfter || inputs.endingBefore) { + const cursor = inputs.startingAfter || inputs.endingBefore; + + try { + const cursorData = JSON.parse( + Buffer.from(cursor ?? '', 'base64').toString(), + ); + + // We always include ID in the ordering to ensure consistent pagination results + // Even if two records have identical values for the user-specified sort fields, their IDs ensures a deterministic order + const orderByWithIdCondition = [ + ...(inputs.orderBy || []), + { id: 'ASC' }, + ]; + + const cursorFilter = await this.computeCursorFilter( + cursorData, + orderByWithIdCondition, + fieldMetadataMapByName, + inputs.isForwardPagination, + ); + + // Combine cursor filter with any user-provided filters + appliedFilters = inputs.filter + ? { and: [inputs.filter, cursorFilter] } + : cursorFilter; + } catch (error) { + throw new BadRequestException(`Invalid cursor: ${cursor}`); + } + } + + // Apply filters to query builder + const finalQuery = new GraphqlQueryFilterConditionParser( + fieldMetadataMapByName, + ).parse(qb, objectMetadataNameSingular, appliedFilters); + + return { finalQuery, appliedFilters }; + } + + private async getTotalCount( + query: SelectQueryBuilder, + ): Promise { + // Clone the query to avoid modifying the original query that will fetch records + const countQuery = query.clone(); + + return await countQuery.getCount(); + } + + private async getRecordsWithPagination( + query: SelectQueryBuilder, + objectMetadataNameSingular: string, + objectMetadataItemWithFieldsMaps: + | ObjectMetadataItemWithFieldMaps + | undefined, + inputs: { + orderBy: any; + limit: number; + isForwardPagination: boolean; + }, + ) { + const fieldMetadataMapByName = + objectMetadataItemWithFieldsMaps?.fieldsByName || {}; + + // Get parsed order by + const parsedOrderBy = new GraphqlQueryOrderFieldParser( + fieldMetadataMapByName, + ).parse(inputs.orderBy, objectMetadataNameSingular); + + // For backward pagination (endingBefore), we need to reverse the sort order + const finalOrderBy = inputs.isForwardPagination + ? parsedOrderBy + : Object.entries(parsedOrderBy).reduce((acc, [key, direction]) => { + acc[key] = direction === 'ASC' ? 'DESC' : 'ASC'; + + return acc; + }, {}); + + // Fetch one extra record beyond the requested limit + // We'll remove it from the results before returning to the client + const records = await query + .orderBy(finalOrderBy as OrderByCondition) + .take(inputs.limit + 1) + .getMany(); + + // If we got more records than the limit, it means there are more pages + const hasMoreRecords = records.length > inputs.limit; + + // Remove the extra record if we fetched more than requested + if (hasMoreRecords) { + records.pop(); + } + + // For backward pagination, we reversed the order to get the correct records + // Now we need to reverse them back to maintain the expected order for the client + const finalRecords = !inputs.isForwardPagination + ? records.reverse() + : records; + + return { finalRecords, hasMoreRecords }; + } + + private formatPaginatedResult( + finalRecords: any[], + objectMetadataNameSingular: string, + objectMetadataItemWithFieldsMaps: any, + objectMetadata: any, + isForwardPagination: boolean, + hasMoreRecords: boolean, + totalCount: number, + ) { + return this.formatResult({ + operation: 'findMany', + objectNamePlural: objectMetadataNameSingular, + data: formatGetManyData( + finalRecords, + objectMetadataItemWithFieldsMaps as any, + objectMetadata.objectMetadataMaps, + ), + meta: { + hasNextPage: isForwardPagination && hasMoreRecords, + hasPreviousPage: !isForwardPagination && hasMoreRecords, + startCursor: + finalRecords.length > 0 + ? Buffer.from(JSON.stringify({ id: finalRecords[0].id })).toString( + 'base64', + ) + : null, + endCursor: + finalRecords.length > 0 + ? Buffer.from( + JSON.stringify({ + id: finalRecords[finalRecords.length - 1].id, + }), + ).toString('base64') + : null, + totalCount, + }, + }); + } + + private formatResult({ + operation, + objectNameSingular, + objectNamePlural, + data, + meta, + }: FormatResultParams) { + let prefix: string; + + if (operation === 'findOne') { + prefix = objectNameSingular || ''; + } else if (operation === 'findMany') { + prefix = objectNamePlural || ''; + } else { + prefix = operation + capitalize(objectNameSingular || ''); + } const result = { data: { - [operation + capitalize(objectNameSingular)]: data, + [prefix]: data, }, }; + if (meta) { + const { totalCount, ...rest } = meta; + + (result.data as any).pageInfo = { ...rest }; + + (result.data as any).totalCount = totalCount; + } + return result; } @@ -107,12 +433,36 @@ export class RestApiCoreServiceV2 { const objectMetadataNameSingular = objectMetadata.objectMetadataMapItem.nameSingular; + + const objectMetadataItemWithFieldsMaps = + getObjectMetadataMapItemByNameSingular( + objectMetadata.objectMetadataMaps, + objectMetadataNameSingular, + ); + const repository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspace.id, objectMetadataNameSingular, ); - return { objectMetadataNameSingular, repository }; + return { + objectMetadataNameSingular, + objectMetadata, + repository, + objectMetadataItemWithFieldsMaps, + }; + } + + // Helper method to compute cursor filter + private async computeCursorFilter( + cursorData: Record, + orderByWithIdCondition: any[], + fieldMetadataMapByName: FieldMetadataMap, + isForwardPagination: boolean, + ): Promise { + return { + id: isForwardPagination ? { gt: cursorData.id } : { lt: cursorData.id }, + }; } } diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/__tests__/order-by-input.factory.spec.ts b/packages/twenty-server/src/engine/api/rest/input-factories/__tests__/order-by-input.factory.spec.ts index d61fc5bcb..fd5a0a4e0 100644 --- a/packages/twenty-server/src/engine/api/rest/input-factories/__tests__/order-by-input.factory.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/input-factories/__tests__/order-by-input.factory.spec.ts @@ -6,7 +6,10 @@ import { objectMetadataItemMock } from 'src/engine/api/__mocks__/object-metadata import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory'; describe('OrderByInputFactory', () => { - const objectMetadata = { objectMetadataItem: objectMetadataItemMock }; + const objectMetadata = { + objectMetadataItem: objectMetadataItemMock, + objectMetadataMapItem: objectMetadataItemMock, + }; let service: OrderByInputFactory; diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts index 25a3700e0..63d61059c 100644 --- a/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts @@ -72,7 +72,7 @@ export class OrderByInputFactory { result = [...result, ...resultFields]; } - checkArrayFields(objectMetadata.objectMetadataItem, result); + checkArrayFields(objectMetadata.objectMetadataMapItem, result); return result; } diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts index a0f38ecb4..36ead9493 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts @@ -1,5 +1,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller'; import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller'; @@ -7,13 +8,18 @@ import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/c import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service'; import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service'; import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory'; +import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory'; import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory'; +import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory'; import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory'; import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module'; import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller'; import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -25,6 +31,8 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ AuthModule, HttpModule, TwentyORMModule, + TypeOrmModule.forFeature([FeatureFlag], 'core'), + FeatureFlagModule, ], controllers: [ RestApiMetadataController, @@ -36,9 +44,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ RestApiCoreService, RestApiCoreServiceV2, RestApiService, + FeatureFlagService, StartingAfterInputFactory, EndingBeforeInputFactory, LimitInputFactory, + FilterInputFactory, + OrderByInputFactory, ], exports: [RestApiMetadataService], }) diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts new file mode 100644 index 000000000..da09de97c --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts @@ -0,0 +1,252 @@ +import { + PERSON_1_ID, + PERSON_2_ID, + PERSON_3_ID, +} from 'test/integration/constants/mock-person-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; + +describe.skip('Core REST API Find Many endpoint', () => { + const testPersonIds = [PERSON_1_ID, PERSON_2_ID, PERSON_3_ID]; + const testPersonCities: Record = {}; + + beforeAll(async () => { + // Create test people with different cities for testing + for (const personId of testPersonIds) { + const city = generateRecordName(personId); + + testPersonCities[personId] = city; + + await makeRestAPIRequest({ + method: 'post', + path: '/people', + body: { + id: personId, + city: city, + // Add different jobTitles to test filtering + jobTitle: `Job ${personId.slice(0, 4)}`, + }, + }).expect(201); + } + }); + + afterAll(async () => { + // Clean up test people + for (const personId of testPersonIds) { + await makeRestAPIRequest({ + method: 'delete', + path: `/people/${personId}`, + }).expect(200); + } + }); + + it('5.a. should retrieve all people with pagination metadata', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people', + }).expect(200); + + const people = response.body.data.people; + const pageInfo = response.body.data.pageInfo; + const totalCount = response.body.data.totalCount; + + expect(people).toBeDefined(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length); + + // Check that our test people are included in the results + for (const personId of testPersonIds) { + const person = people.find((p) => p.id === personId); + + expect(person).toBeDefined(); + expect(person.city).toBe(testPersonCities[personId]); + } + + // Check pagination metadata + expect(pageInfo).toBeDefined(); + expect(pageInfo.startCursor).toBeDefined(); + expect(pageInfo.endCursor).toBeDefined(); + expect(typeof totalCount).toBe('number'); + expect(totalCount).toBeGreaterThanOrEqual(testPersonIds.length); + }); + + it('5.b. should limit results based on the limit parameter', async () => { + const limit = 2; + const response = await makeRestAPIRequest({ + method: 'get', + path: `/people?limit=${limit}`, + }).expect(200); + + const people = response.body.data.people; + + expect(people).toBeDefined(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toBeLessThanOrEqual(limit); + }); + + it('5.c. should filter results based on filter parameters', async () => { + // Filter by jobTitle starting with "Job" + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?filter[jobTitle][contains]=Job', + }).expect(200); + + const people = response.body.data.people; + + expect(people).toBeDefined(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length); + + // All returned people should have jobTitle containing "Job" + for (const person of people) { + expect(person.jobTitle).toContain('Job'); + } + }); + + it('5.d. should support cursor-based pagination with startingAfter', async () => { + // First, get initial results to get a cursor + const initialResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?limit=1', + }).expect(200); + + const firstPerson = initialResponse.body.data.people[0]; + const endCursor = initialResponse.body.data.pageInfo.endCursor; + + expect(firstPerson).toBeDefined(); + expect(endCursor).toBeDefined(); + + // Now use the cursor to get the next page + const nextPageResponse = await makeRestAPIRequest({ + method: 'get', + path: `/people?startingAfter=${endCursor}&limit=1`, + }).expect(200); + + const nextPagePeople = nextPageResponse.body.data.people; + + expect(nextPagePeople).toBeDefined(); + expect(nextPagePeople.length).toBe(1); + expect(nextPagePeople[0].id).not.toBe(firstPerson.id); + }); + + it('5.e. should support cursor-based pagination with endingBefore', async () => { + // First, get results to get a cursor from the second page + const initialResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?limit=2', + }).expect(200); + + const people = initialResponse.body.data.people; + const startCursor = initialResponse.body.data.pageInfo.startCursor; + + expect(people.length).toBe(2); + expect(startCursor).toBeDefined(); + + // Now use the cursor to get the previous page (which should be empty in this case) + const prevPageResponse = await makeRestAPIRequest({ + method: 'get', + path: `/people?endingBefore=${startCursor}&limit=1`, + }).expect(200); + + const prevPagePeople = prevPageResponse.body.data.people; + const pageInfo = prevPageResponse.body.data.pageInfo; + + // Since we're at the beginning, there might not be previous results + expect(prevPagePeople).toBeDefined(); + expect(pageInfo.hasPreviousPage).toBeDefined(); + }); + + it('5.f. should support ordering of results', async () => { + // Order by city in ascending order + const ascResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?orderBy[city]=ASC', + }).expect(200); + + const ascPeople = ascResponse.body.data.people; + + // Check that cities are in ascending order + for (let i = 1; i < ascPeople.length; i++) { + if (ascPeople[i - 1].city && ascPeople[i].city) { + expect(ascPeople[i - 1].city <= ascPeople[i].city).toBe(true); + } + } + + // Order by city in descending order + const descResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?orderBy[city]=DESC', + }).expect(200); + + const descPeople = descResponse.body.data.people; + + // Check that cities are in descending order + for (let i = 1; i < descPeople.length; i++) { + if (descPeople[i - 1].city && descPeople[i].city) { + expect(descPeople[i - 1].city >= descPeople[i].city).toBe(true); + } + } + }); + + it('5.g. should return an UnauthorizedException when no token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: '/people', + headers: { authorization: '' }, + }) + .expect(401) + .expect((res) => { + expect(res.body.error).toBe('UNAUTHENTICATED'); + }); + }); + + it('5.h. should return an UnauthorizedException when an invalid token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: '/people', + headers: { authorization: 'Bearer invalid-token' }, + }) + .expect(401) + .expect((res) => { + expect(res.body.error).toBe('UNAUTHENTICATED'); + }); + }); + + it('5.i. should handle invalid cursor gracefully', async () => { + await makeRestAPIRequest({ + method: 'get', + path: '/people?startingAfter=invalid-cursor', + }) + .expect(400) + .expect((res) => { + expect(res.body.error).toBe('BadRequestException'); + expect(res.body.messages[0]).toContain('Invalid cursor'); + }); + }); + + it('5.j. should combine filtering, ordering, and pagination', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?filter[jobTitle][contains]=Job&orderBy[city]=ASC&limit=2', + }).expect(200); + + const people = response.body.data.people; + const pageInfo = response.body.data.pageInfo; + + expect(people).toBeDefined(); + expect(people.length).toBeLessThanOrEqual(2); + expect(pageInfo).toBeDefined(); + + // Check that all returned people match the filter + for (const person of people) { + expect(person.jobTitle).toContain('Job'); + } + + // Check that cities are in ascending order + for (let i = 1; i < people.length; i++) { + if (people[i - 1].city && people[i].city) { + expect(people[i - 1].city <= people[i].city).toBe(true); + } + } + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts new file mode 100644 index 000000000..b3bc17df6 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts @@ -0,0 +1,93 @@ +import { + FAKE_PERSON_ID, + PERSON_1_ID, +} from 'test/integration/constants/mock-person-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; + +describe.skip('Core REST API Find One endpoint', () => { + let personCity: string; + + beforeAll(async () => { + personCity = generateRecordName(PERSON_1_ID); + + // Create a test person to retrieve + await makeRestAPIRequest({ + method: 'post', + path: '/people', + body: { + id: PERSON_1_ID, + city: personCity, + }, + }).expect(201); + }); + + afterAll(async () => { + // Clean up the test person + await makeRestAPIRequest({ + method: 'delete', + path: `/people/${PERSON_1_ID}`, + }).expect(200); + }); + + it('4.a. should retrieve a person by ID', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/people/${PERSON_1_ID}`, + }).expect(200); + + const person = response.body.data.person; + + expect(person).toBeDefined(); + expect(person.id).toBe(PERSON_1_ID); + expect(person.city).toBe(personCity); + }); + + it('4.b. should return null when trying to retrieve a non-existing person', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/people/${FAKE_PERSON_ID}`, + }).expect(200); + + const person = response.body.data.person; + + expect(person).toBeNull(); + }); + + it('4.c. should return an UnauthorizedException when no token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${PERSON_1_ID}`, + headers: { authorization: '' }, + }) + .expect(401) + .expect((res) => { + expect(res.body.error).toBe('UNAUTHENTICATED'); + }); + }); + + it('4.d. should return an UnauthorizedException when an invalid token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${PERSON_1_ID}`, + headers: { authorization: 'Bearer invalid-token' }, + }) + .expect(401) + .expect((res) => { + expect(res.body.error).toBe('UNAUTHENTICATED'); + }); + }); + + it('4.e. should return an UnauthorizedException when an expired token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${PERSON_1_ID}`, + headers: { authorization: `Bearer ${EXPIRED_ACCESS_TOKEN}` }, + }) + .expect(401) + .expect((res) => { + expect(res.body.error).toBe('UNAUTHENTICATED'); + expect(res.body.messages[0]).toBe('Token has expired.'); + }); + }); +});