diff --git a/packages/twenty-e2e-testing/tests/create-record.spec.ts b/packages/twenty-e2e-testing/tests/create-record.spec.ts index 40d3823f45..ed0321a1e8 100644 --- a/packages/twenty-e2e-testing/tests/create-record.spec.ts +++ b/packages/twenty-e2e-testing/tests/create-record.spec.ts @@ -111,8 +111,8 @@ test('Create and update record', async ({ page }) => { await companyRelationWidget.hover(); await companyRelationWidget.locator('.tabler-icon-pencil').click(); - await page.getByRole('textbox', { name: 'Search' }).fill('Goog'); - await expect(page.getByRole('option', { name: 'Google' })).toBeVisible(); + await page.getByRole('textbox', { name: 'Search' }).fill('VMw'); + await expect(page.getByRole('option', { name: 'VMware' })).toBeVisible(); const [updatePersonResponse] = await Promise.all([ page.waitForResponse(async (response) => { if (!response.url().endsWith('/graphql')) { @@ -123,7 +123,7 @@ test('Create and update record', async ({ page }) => { return requestBody.operationName === 'UpdateOnePerson'; }), - await page.getByRole('option', { name: 'Google' }).click({force: true}) + await page.getByRole('option', { name: 'VMware' }).click({force: true}) ]); const body = await updatePersonResponse.json() @@ -153,6 +153,6 @@ test('Create and update record', async ({ page }) => { expect(findOnePersonReponseBody.data.person.linkedinLink.primaryLinkUrl).toBe('linkedin.com/johndoe'); expect(findOnePersonReponseBody.data.person.phones.primaryPhoneNumber).toBe('611223344'); expect(findOnePersonReponseBody.data.person.workPreference).toEqual(['HYBRID']); - expect(findOnePersonReponseBody.data.person.company.name).toBe('Google'); + expect(findOnePersonReponseBody.data.person.company.name).toBe('VMware'); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts index 2819918d08..2f79abe035 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts @@ -255,46 +255,4 @@ describe('computeOptimisticRecordFromInput', () => { `"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadataItem person"`, ); }); - - it('should throw an error if recordInput contains both the relationFieldId and relationField', () => { - const cache = new InMemoryCache(); - const personObjectMetadataItem = getMockPersonObjectMetadataItem(); - - expect(() => - computeOptimisticRecordFromInput({ - currentWorkspaceMember, - objectMetadataItems: generatedMockObjectMetadataItems, - objectMetadataItem: personObjectMetadataItem, - recordInput: { - companyId: '123', - company: {}, - }, - cache, - objectPermissionsByObjectMetadataId: {}, - }), - ).toThrowErrorMatchingInlineSnapshot( - `"Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: company"`, - ); - }); - - it('should throw an error if recordInput contains both the relationFieldId and relationField even if null', () => { - const cache = new InMemoryCache(); - const personObjectMetadataItem = getMockPersonObjectMetadataItem(); - - expect(() => - computeOptimisticRecordFromInput({ - currentWorkspaceMember, - objectMetadataItems: generatedMockObjectMetadataItems, - objectMetadataItem: personObjectMetadataItem, - recordInput: { - companyId: '123', - company: null, - }, - cache, - objectPermissionsByObjectMetadataId: {}, - }), - ).toThrowErrorMatchingInlineSnapshot( - `"Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: company"`, - ); - }); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts index e27c4ce0a8..ec19e8eb08 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts @@ -140,12 +140,6 @@ export const computeOptimisticRecordFromInput = ({ continue; } - if (!isUndefined(recordInputFieldValue)) { - throw new Error( - `Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: ${fieldMetadataItem.name}`, - ); - } - const relationGqlFieldWithId = getForeignKeyNameFromRelationFieldName( fieldMetadataItem.name, ); @@ -202,12 +196,6 @@ export const computeOptimisticRecordFromInput = ({ continue; } - if (!isUndefined(recordInputFieldValue)) { - throw new Error( - `Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: ${fieldMetadataItem.name}`, - ); - } - const relationGqlFields = fieldMetadataItem.morphRelations?.map( (morphRelation) => { return computeMorphRelationFieldName({ diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts index 41015a7b75..31162105f8 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts @@ -73,70 +73,67 @@ export const useTriggerEventStreamCreation = () => { }, }, { - next: ( - value: ExecutionResult<{ - onEventSubscription: EventSubscription; - }>, - ) => { - if (isDefined(value?.errors)) { - captureException( - new Error( - `SSE subscription error: ${value.errors[0]?.message}`, - ), - ); - set(shouldDestroyEventStreamState, true); - - return; - } - - if (!hasReceivedFirstEvent) { - hasReceivedFirstEvent = true; - set(sseEventStreamReadyState, true); - } - - const objectRecordEventsWithQueryIds = - value?.data?.onEventSubscription?.eventWithQueryIdsList ?? []; - - const objectRecordEvents = objectRecordEventsWithQueryIds.map( - (eventWithQueryIds) => { - return eventWithQueryIds.event; - }, - ); - - triggerOptimisticEffectFromSseEvents({ - objectRecordEvents, - }); - - dispatchObjectRecordEventsFromSseToBrowserEvents( - objectRecordEventsWithQueryIds, - ); - }, - error: (error) => { - captureException(error); - }, complete: () => {}, + error: () => {}, + next: () => {}, }, { message: ({ data, event }) => { - if (event === 'next') { - if (isDefined(data?.errors)) { - const subCode = data.errors[0]?.extensions?.subCode; + const result = data as ExecutionResult<{ + onEventSubscription: EventSubscription; + }>; - switch (subCode) { - case 'EVENT_STREAM_ALREADY_EXISTS': { - set(shouldDestroyEventStreamState, true); - break; + try { + if (event === 'next') { + if (isDefined(result?.errors)) { + const subCode = result.errors[0]?.extensions?.subCode; + + switch (subCode) { + case 'EVENT_STREAM_ALREADY_EXISTS': { + set(shouldDestroyEventStreamState, true); + break; + } + default: { + for (const error of result.errors) { + captureException(error); + } + } } - default: { - captureException( - new Error( - `Unhandled SSE message error: ${data.errors[0]?.message}`, - ), + + set(shouldDestroyEventStreamState, true); + } else { + if (!hasReceivedFirstEvent) { + hasReceivedFirstEvent = true; + set(sseEventStreamReadyState, true); + } + + const objectRecordEventsWithQueryIds = + result?.data?.onEventSubscription + ?.eventWithQueryIdsList ?? []; + + const objectRecordEvents = + objectRecordEventsWithQueryIds.map( + (eventWithQueryIds) => { + return eventWithQueryIds.event; + }, ); - break; - } + + triggerOptimisticEffectFromSseEvents({ + objectRecordEvents, + }); + + dispatchObjectRecordEventsFromSseToBrowserEvents( + objectRecordEventsWithQueryIds, + ); } } + } catch (error) { + const errorProcessingSSEMessage = new Error( + 'Error while processing SSE message', + { cause: error instanceof Error ? error : undefined }, + ); + + captureException(errorProcessingSSEMessage); } }, }, diff --git a/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts b/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts deleted file mode 100644 index 7cac55b224..0000000000 --- a/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; - -import { - CommonQueryRunnerException, - CommonQueryRunnerExceptionCode, -} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; -import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; -import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util'; -import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; -import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; -import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; - -@Injectable() -export class CommonSelectedFieldsHandler { - computeFromDepth = ({ - objectsPermissions, - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatObjectMetadata, - depth, - }: { - objectsPermissions: ObjectsPermissions; - flatObjectMetadataMaps: FlatEntityMaps; - flatFieldMetadataMaps: FlatEntityMaps; - flatObjectMetadata: FlatObjectMetadata; - depth: Depth | undefined; - }): CommonSelectedFieldsResult => { - const restrictedFields = - objectsPermissions[flatObjectMetadata.id].restrictedFields; - - const { relations, relationsSelectFields } = - this.getRelationsAndRelationsSelectFields({ - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatObjectMetadata, - objectsPermissions, - depth, - }); - - const selectableFields = getAllSelectableFields({ - restrictedFields, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - return { - select: { - ...selectableFields, - ...relationsSelectFields, - }, - relations, - aggregate: {}, - }; - }; - - private getRelationsAndRelationsSelectFields({ - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatObjectMetadata, - objectsPermissions, - depth, - }: { - flatObjectMetadataMaps: FlatEntityMaps; - flatFieldMetadataMaps: FlatEntityMaps; - flatObjectMetadata: FlatObjectMetadata; - objectsPermissions: ObjectsPermissions; - depth: Depth | undefined; - }) { - if (!isDefined(depth) || depth === 0) { - return { - relations: {}, - relationsSelectFields: {}, - }; - } - - let relations: { [key: string]: boolean | { [key: string]: boolean } } = {}; - - let relationsSelectFields: { - [key: string]: - | boolean - | { [key: string]: boolean | { [key: string]: boolean } }; - } = {}; - - for (const fieldId of flatObjectMetadata.fieldIds) { - const field = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatFieldMetadataMaps, - flatEntityId: fieldId, - }); - - if (!isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION)) - continue; - - if (!field.relationTargetObjectMetadataId) continue; - - const relationTargetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: field.relationTargetObjectMetadataId, - flatEntityMaps: flatObjectMetadataMaps, - }); - - if (!isDefined(relationTargetObjectMetadata)) { - throw new CommonQueryRunnerException( - `Object metadata relation target not found for relation creation payload`, - CommonQueryRunnerExceptionCode.BAD_REQUEST, - { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, - ); - } - const relationFieldSelectFields = getAllSelectableFields({ - restrictedFields: - objectsPermissions[relationTargetObjectMetadata.id].restrictedFields, - flatObjectMetadata: relationTargetObjectMetadata, - flatFieldMetadataMaps, - }); - - if (Object.keys(relationFieldSelectFields).length === 0) continue; - - if ( - depth === MAX_DEPTH && - isDefined(field.relationTargetObjectMetadataId) - ) { - const { - relations: depth2Relations, - relationsSelectFields: depth2RelationsSelectFields, - } = this.getRelationsAndRelationsSelectFields({ - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatObjectMetadata: relationTargetObjectMetadata, - objectsPermissions, - depth: 1, - }) as { - relations: { [key: string]: boolean }; - relationsSelectFields: { - [key: string]: boolean; - }; - }; - - relations[field.name] = depth2Relations as { - [key: string]: boolean; - }; - - relationsSelectFields[field.name] = { - ...relationFieldSelectFields, - ...depth2RelationsSelectFields, - }; - } else { - relations[field.name] = true; - relationsSelectFields[field.name] = relationFieldSelectFields; - } - } - - return { relations, relationsSelectFields }; - } -} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts similarity index 100% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts rename to packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper.ts similarity index 94% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts rename to packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper.ts index 48cef4944e..809e494eb1 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts +++ b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { type ObjectRecord } from 'twenty-shared/types'; import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm'; -import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; +import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper'; import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index 7e16611f61..23b694c974 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -7,6 +7,7 @@ import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-ru import { DataArgProcessor } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor'; import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, @@ -25,7 +26,6 @@ import { CommonQueryResult } from 'src/engine/api/common/types/common-query-resu import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type'; import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts similarity index 66% rename from packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts rename to packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts index 5bebfbc36b..985e4ed791 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts @@ -3,8 +3,10 @@ import { Injectable } from '@nestjs/common'; import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util'; +import { getIsFlatFieldAJoinColumn } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util'; +import { getIsFlatFieldAJunctionRelationField } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field'; import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; -import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util'; import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; @@ -18,19 +20,23 @@ type SelectFields = { }; @Injectable() -export class RestToCommonSelectedFieldsHandler { +export class CommonSelectFieldsHelper { computeFromDepth = ({ objectsPermissions, flatObjectMetadataMaps, flatFieldMetadataMaps, flatObjectMetadata, depth, + onlyUseLabelIdentifierFieldsInRelations = false, + recurseIntoJunctionTableRelations = false, }: { objectsPermissions: ObjectsPermissions; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; flatObjectMetadata: FlatObjectMetadata; depth: Depth | undefined; + onlyUseLabelIdentifierFieldsInRelations?: boolean; + recurseIntoJunctionTableRelations?: boolean; }): CommonSelectedFields => { const restrictedFields = objectsPermissions[flatObjectMetadata.id].restrictedFields; @@ -41,6 +47,8 @@ export class RestToCommonSelectedFieldsHandler { flatObjectMetadata, objectsPermissions, depth, + onlyUseLabelIdentifierFieldsInRelations, + recurseIntoJunctionTableRelations, }); const selectableFields = getAllSelectableFields({ @@ -61,12 +69,18 @@ export class RestToCommonSelectedFieldsHandler { flatObjectMetadata, objectsPermissions, depth, + onlyUseLabelIdentifierFieldsInRelations = false, + currentDepthLevelIsAJunctionTable = false, + recurseIntoJunctionTableRelations = false, }: { flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; flatObjectMetadata: FlatObjectMetadata; objectsPermissions: ObjectsPermissions; depth: Depth | undefined; + onlyUseLabelIdentifierFieldsInRelations?: boolean; + currentDepthLevelIsAJunctionTable?: boolean; + recurseIntoJunctionTableRelations?: boolean; }) { if (!isDefined(depth) || depth === 0) return {}; @@ -78,8 +92,19 @@ export class RestToCommonSelectedFieldsHandler { flatEntityId: fieldId, }); - if (!isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION)) + if (!isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION)) { continue; + } + + if (currentDepthLevelIsAJunctionTable) { + const fieldIsJunctionRelation = getIsFlatFieldAJunctionRelationField({ + flatField, + }); + + if (!fieldIsJunctionRelation) { + continue; + } + } const relationTargetObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ @@ -92,26 +117,39 @@ export class RestToCommonSelectedFieldsHandler { objectsPermissions[relationTargetObjectMetadata.id].restrictedFields, flatObjectMetadata: relationTargetObjectMetadata, flatFieldMetadataMaps, + onlyUseLabelIdentifierFieldsInRelations, }); if (Object.keys(relationFieldSelectFields).length === 0) continue; - if ( + const flatFieldIsJoinColumn = getIsFlatFieldAJoinColumn({ flatField }); + + const isFirstDepthLevel = depth === MAX_DEPTH && - isDefined(flatField.relationTargetObjectMetadataId) - ) { - const depth2RelationsSelectFields = + isDefined(flatField.relationTargetObjectMetadataId); + + const shouldRecurseIntoRelation = + isFirstDepthLevel || + (flatFieldIsJoinColumn && recurseIntoJunctionTableRelations); + + const nextLevelIsAJunctionTable = flatFieldIsJoinColumn; + + if (shouldRecurseIntoRelation) { + const nestedRelationFieldSelectFields = this.getRelationsAndRelationsSelectFields({ flatObjectMetadataMaps, flatFieldMetadataMaps, flatObjectMetadata: relationTargetObjectMetadata, objectsPermissions, depth: 1, + onlyUseLabelIdentifierFieldsInRelations, + currentDepthLevelIsAJunctionTable: nextLevelIsAJunctionTable, + recurseIntoJunctionTableRelations, }); relationsSelectFields[flatField.name] = { ...relationFieldSelectFields, - ...depth2RelationsSelectFields, + ...nestedRelationFieldSelectFields, }; } else { relationsSelectFields[flatField.name] = relationFieldSelectFields; diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts new file mode 100644 index 0000000000..32c2a22da2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts @@ -0,0 +1,645 @@ +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; + +import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +describe('getAllSelectableFields', () => { + const createMockField = ( + overrides: Partial & { + id: string; + name: string; + type: FieldMetadataType; + }, + ): FlatFieldMetadata => + ({ + objectMetadataId: 'object-id', + workspaceId: 'workspace-id', + isNullable: true, + isLabelSyncedWithName: true, + createdAt: new Date(), + updatedAt: new Date(), + universalIdentifier: overrides.id, + viewFieldIds: [], + viewFilterIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], + applicationId: null, + label: overrides.name, + ...overrides, + }) as FlatFieldMetadata; + + const buildFlatFieldMetadataMaps = ( + fields: FlatFieldMetadata[], + ): FlatEntityMaps => ({ + byUniversalIdentifier: fields.reduce( + (acc, field) => { + acc[field.universalIdentifier] = field; + + return acc; + }, + {} as Record, + ), + universalIdentifierById: fields.reduce( + (acc, field) => { + acc[field.id] = field.universalIdentifier; + + return acc; + }, + {} as Record, + ), + universalIdentifiersByApplicationId: {}, + }); + + const buildFlatObjectMetadata = (fieldIds: string[]): FlatObjectMetadata => + ({ + id: 'object-id', + workspaceId: 'workspace-id', + nameSingular: 'testObject', + namePlural: 'testObjects', + labelSingular: 'Test Object', + labelPlural: 'Test Objects', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + createdAt: new Date(), + updatedAt: new Date(), + universalIdentifier: 'object-id', + fieldIds: fieldIds, + indexMetadataIds: [], + viewIds: [], + applicationId: null, + }) as unknown as FlatObjectMetadata; + + it('should return all fields as selectable when no restrictions', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); + const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + email: true, + }); + }); + + it('should not return restricted fields', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); + const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-2': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + }); + }); + + it('should create nested objects for composite fields', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'fullName', + type: FieldMetadataType.FULL_NAME, + }); + const field3 = createMockField({ + id: 'field-3', + name: 'domainName', + type: FieldMetadataType.LINKS, + }); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + field1, + field2, + field3, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + 'field-3', + ]); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + fullName: { + firstName: true, + lastName: true, + }, + domainName: { + primaryLinkLabel: true, + primaryLinkUrl: true, + secondaryLinks: true, + }, + }); + }); + + it('should restrict all sub-fields when composite field is restricted', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'fullName', + type: FieldMetadataType.FULL_NAME, + }); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); + const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-2': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + }); + }); + + it('should handle mixed regular and composite fields with restrictions', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + const field3 = createMockField({ + id: 'field-3', + name: 'fullName', + type: FieldMetadataType.FULL_NAME, + }); + const field4 = createMockField({ + id: 'field-4', + name: 'address', + type: FieldMetadataType.ADDRESS, + }); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + field1, + field2, + field3, + field4, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + 'field-3', + 'field-4', + ]); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-2': { canRead: false }, + 'field-4': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + fullName: { + firstName: true, + lastName: true, + }, + }); + }); + + describe('onlyUseLabelIdentifierFields flag', () => { + it('should only return label identifier, id, and image identifier fields when flag is true', () => { + const idField = createMockField({ + id: 'field-id', + name: 'id', + type: FieldMetadataType.UUID, + }); + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const emailField = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + const domainNameField = createMockField({ + id: 'field-3', + name: 'domainName', + type: FieldMetadataType.LINKS, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + idField, + nameField, + emailField, + domainNameField, + ]); + const flatObjectMetadata = { + ...buildFlatObjectMetadata([ + 'field-id', + 'field-1', + 'field-2', + 'field-3', + ]), + labelIdentifierFieldMetadataId: 'field-1', + imageIdentifierFieldMetadataId: 'field-3', + }; + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + onlyUseLabelIdentifierFieldsInRelations: true, + }); + + expect(result).toEqual({ + id: true, + name: true, + domainName: { + primaryLinkLabel: true, + primaryLinkUrl: true, + secondaryLinks: true, + }, + }); + }); + + it('should include domainName for company objects even if not image identifier', () => { + const idField = createMockField({ + id: 'field-id', + name: 'id', + type: FieldMetadataType.UUID, + }); + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const domainNameField = createMockField({ + id: 'field-2', + name: 'domainName', + type: FieldMetadataType.LINKS, + }); + const emailField = createMockField({ + id: 'field-3', + name: 'email', + type: FieldMetadataType.TEXT, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + idField, + nameField, + domainNameField, + emailField, + ]); + const flatObjectMetadata = { + ...buildFlatObjectMetadata([ + 'field-id', + 'field-1', + 'field-2', + 'field-3', + ]), + nameSingular: 'company', + labelIdentifierFieldMetadataId: 'field-1', + }; + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + onlyUseLabelIdentifierFieldsInRelations: true, + }); + + expect(result).toEqual({ + id: true, + name: true, + domainName: { + primaryLinkLabel: true, + primaryLinkUrl: true, + secondaryLinks: true, + }, + }); + }); + + it('should respect restrictions even with onlyUseLabelIdentifierFields flag', () => { + const idField = createMockField({ + id: 'field-id', + name: 'id', + type: FieldMetadataType.UUID, + }); + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const emailField = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + idField, + nameField, + emailField, + ]); + const flatObjectMetadata = { + ...buildFlatObjectMetadata(['field-id', 'field-1', 'field-2']), + labelIdentifierFieldMetadataId: 'field-1', + }; + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-1': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + onlyUseLabelIdentifierFieldsInRelations: true, + }); + + // Label identifier field is restricted, so it should not be returned + // even with the flag + expect(result).toEqual({ + id: true, + }); + }); + }); + + describe('MANY_TO_ONE relation fields', () => { + it('should return join column name for MANY_TO_ONE relations', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const companyRelationField = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + companyRelationField, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + companyId: true, // join column name instead of relation name + }); + }); + + it('should not return join column if relation field is restricted', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const companyRelationField = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + companyRelationField, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-2': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + }); + }); + + it('should include ONE_TO_MANY relation fields as regular fields', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const peopleRelationField = createMockField({ + id: 'field-2', + name: 'people', + type: FieldMetadataType.RELATION, + settings: { + relationType: RelationType.ONE_TO_MANY, + }, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + peopleRelationField, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + people: true, // ONE_TO_MANY relations are included as regular fields + }); + }); + + it('should include MANY_TO_ONE relations without joinColumnName as regular fields', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const relationFieldWithoutJoinColumn = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + relationFieldWithoutJoinColumn, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + company: true, // MANY_TO_ONE without joinColumnName are included as regular fields + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty field list', () => { + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([]); + const flatObjectMetadata = buildFlatObjectMetadata([]); + + const result = getAllSelectableFields({ + restrictedFields: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({}); + }); + + it('should handle all fields restricted', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + field1, + field2, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-1': { canRead: false }, + 'field-2': { canRead: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({}); + }); + + it('should handle canRead: true in restrictedFields', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + field1, + field2, + ]); + const flatObjectMetadata = buildFlatObjectMetadata([ + 'field-1', + 'field-2', + ]); + + const result = getAllSelectableFields({ + restrictedFields: { + 'field-1': { canRead: true, canUpdate: false }, + 'field-2': { canRead: false, canUpdate: false }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + name: true, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts similarity index 78% rename from packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util.ts rename to packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts index bd4ea7f6a2..df316e9b6d 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts @@ -1,3 +1,7 @@ +import { + checkIfFieldIsImageIdentifier, + checkIfFieldIsLabelIdentifier, +} from 'twenty-shared/metadata'; import { FieldMetadataType, RelationType, @@ -22,10 +26,12 @@ export const getAllSelectableFields = ({ restrictedFields, flatObjectMetadata, flatFieldMetadataMaps, + onlyUseLabelIdentifierFieldsInRelations = false, }: { restrictedFields: RestrictedFieldsPermissions; flatObjectMetadata: FlatObjectMetadata; flatFieldMetadataMaps: FlatEntityMaps; + onlyUseLabelIdentifierFieldsInRelations?: boolean; }): SelectableFieldsStructured => { const result: SelectableFieldsStructured = {}; @@ -37,6 +43,26 @@ export const getAllSelectableFields = ({ if (restrictedFields[flatField.id]?.canRead === false) continue; + if (onlyUseLabelIdentifierFieldsInRelations) { + const fieldIsLabelIdentifier = checkIfFieldIsLabelIdentifier( + flatField, + flatObjectMetadata, + ); + const fieldIsImageIdentifier = checkIfFieldIsImageIdentifier( + flatField, + flatObjectMetadata, + ); + const fieldIsIdField = flatField.name === 'id'; + + if ( + !fieldIsLabelIdentifier && + !fieldIsImageIdentifier && + !fieldIsIdField + ) { + continue; + } + } + if (isCompositeFieldMetadataType(flatField.type)) { const compositeType = compositeTypeDefinitions.get(flatField.type); diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util.ts new file mode 100644 index 0000000000..e9bbe85d9a --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util.ts @@ -0,0 +1,19 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export const getIsFlatFieldAJoinColumn = ({ + flatField, +}: { + flatField: FlatFieldMetadata; +}): boolean => { + const flatFieldIsJoinColumn = + isDefined(flatField.settings) && + 'junctionTargetFieldId' in flatField.settings; + + // TODO: refactor this when we remove hard-coded activity relations + const flatFieldIsActivityTarget = + flatField.name === 'noteTargets' || flatField.name === 'taskTargets'; + + return flatFieldIsJoinColumn || flatFieldIsActivityTarget; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field.ts new file mode 100644 index 0000000000..ef9d9bbbd2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field.ts @@ -0,0 +1,22 @@ +import { RelationType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export const getIsFlatFieldAJunctionRelationField = ({ + flatField, +}: { + flatField: FlatFieldMetadata; +}): boolean => { + const isJunctionRelationField = + isDefined(flatField.settings) && + 'relationType' in flatField.settings && + flatField.settings.relationType === RelationType.MANY_TO_ONE && + isDefined(flatField.settings.joinColumnName); + + // TODO: refactor this when we remove hard-coded activity relations + const isActivityRelationField = + flatField.name === 'note' || flatField.name === 'task'; + + return isJunctionRelationField || isActivityRelationField; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-should-recurse-into-relation.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-should-recurse-into-relation.ts new file mode 100644 index 0000000000..6f8d6b28cc --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-should-recurse-into-relation.ts @@ -0,0 +1,31 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; +import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export const getShouldRecurseIntoRelation = ({ + depth, + flatField, +}: { + depth: Depth | undefined; + flatField: FlatFieldMetadata; +}): boolean => { + const flatFieldIsJoinColumn = + isDefined(flatField.settings) && + 'junctionTargetFieldId' in flatField.settings; + + // TODO: refactor this when we remove hard-coded activity relations + const flatFieldIsActivityTarget = + flatField.name === 'noteTargets' || flatField.name === 'taskTargets'; + + const shouldGoOneLevelDeeper = + depth === MAX_DEPTH && isDefined(flatField.relationTargetObjectMetadataId); + + const shouldRecurseIntoRelation = + shouldGoOneLevelDeeper || + flatFieldIsActivityTarget || + flatFieldIsJoinColumn; + + return shouldRecurseIntoRelation; +}; diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts index 7bce31c787..2f32f15d70 100644 --- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CommonArgsProcessors } from 'src/engine/api/common/common-args-processors/common-args-processors'; +import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners'; import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service'; import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; -import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; -import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts index cde40be7cb..000c5cb8f3 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; -import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; -import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index 8e71a4651b..e7058adff5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -8,6 +8,7 @@ import { FindOptionsRelations, type ObjectLiteral } from 'typeorm'; import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util'; import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; @@ -17,7 +18,6 @@ import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/ import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util'; -import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts index 1b2a3da217..8f899ce904 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts @@ -7,9 +7,9 @@ import { isDefined, } from 'twenty-shared/utils'; +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; -import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler'; import { parseCorePath } from 'src/engine/api/rest/input-request-parsers/path-parser-utils/parse-core-path.utils'; import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; @@ -66,7 +66,7 @@ export abstract class RestApiBaseHandler { @Inject() protected readonly apiKeyRoleService: ApiKeyRoleService; @Inject() - protected readonly restToCommonSelectedFieldsHandler: RestToCommonSelectedFieldsHandler; + protected readonly commonSelectFieldsHelper: CommonSelectFieldsHelper; @Inject() protected readonly userRoleService: UserRoleService; @Inject() @@ -137,7 +137,7 @@ export abstract class RestApiBaseHandler { const { objectsPermissions } = await this.getObjectsPermissions(authContext); - return this.restToCommonSelectedFieldsHandler.computeFromDepth({ + return this.commonSelectFieldsHelper.computeFromDepth({ objectsPermissions, flatObjectMetadataMaps, flatFieldMetadataMaps, diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts new file mode 100644 index 0000000000..ce1d00d531 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts @@ -0,0 +1,692 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { + FieldMetadataType, + type ObjectsPermissions, + RelationType, +} from 'twenty-shared/types'; + +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; +import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +describe('RestToCommonSelectedFieldsHandler', () => { + let handler: CommonSelectFieldsHelper; + + const createMockField = ( + overrides: Partial & { + id: string; + name: string; + type: FieldMetadataType; + objectMetadataId: string; + }, + ): FlatFieldMetadata => + ({ + workspaceId: 'workspace-id', + isNullable: true, + isLabelSyncedWithName: true, + createdAt: new Date(), + updatedAt: new Date(), + universalIdentifier: overrides.id, + viewFieldIds: [], + viewFilterIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], + applicationId: null, + label: overrides.name, + settings: null, + relationTargetObjectMetadataId: null, + relationTargetFieldMetadataId: null, + ...overrides, + }) as FlatFieldMetadata; + + const createMockObjectMetadata = ( + overrides: Partial & { + id: string; + nameSingular: string; + fieldIds: string[]; + }, + ): FlatObjectMetadata => + ({ + workspaceId: 'workspace-id', + namePlural: `${overrides.nameSingular}s`, + labelSingular: overrides.nameSingular, + labelPlural: `${overrides.nameSingular}s`, + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + createdAt: new Date(), + updatedAt: new Date(), + universalIdentifier: overrides.id, + indexMetadataIds: [], + viewIds: [], + applicationId: null, + labelIdentifierFieldMetadataId: null, + imageIdentifierFieldMetadataId: null, + ...overrides, + }) as unknown as FlatObjectMetadata; + + const buildFlatFieldMetadataMaps = ( + fields: FlatFieldMetadata[], + ): FlatEntityMaps => + fields.reduce( + (maps, field) => + addFlatEntityToFlatEntityMapsOrThrow({ + flatEntity: field, + flatEntityMaps: maps, + }), + createEmptyFlatEntityMaps() as FlatEntityMaps, + ); + + const buildFlatObjectMetadataMaps = ( + objects: FlatObjectMetadata[], + ): FlatEntityMaps => + objects.reduce( + (maps, object) => + addFlatEntityToFlatEntityMapsOrThrow({ + flatEntity: object, + flatEntityMaps: maps, + }), + createEmptyFlatEntityMaps() as FlatEntityMaps, + ); + + const createObjectsPermissions = ( + objectIds: string[], + options: { + restrictedFields?: Record< + string, + { canRead: boolean; canUpdate: boolean } + >; + } = {}, + ): ObjectsPermissions => { + return objectIds.reduce((acc, objectId) => { + acc[objectId] = { + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: options.restrictedFields || {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }; + + return acc; + }, {} as ObjectsPermissions); + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CommonSelectFieldsHelper], + }).compile(); + + handler = module.get(CommonSelectFieldsHelper); + }); + + describe('computeFromDepth', () => { + it('should return all selectable fields when depth is undefined', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + const emailField = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-1', 'field-2'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + emailField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions(['person-id']), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: undefined, + }); + + expect(result).toEqual({ + name: true, + email: true, + }); + }); + + it('should include relation fields when depth is 1', () => { + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + const companyRelationField = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'person-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + relationTargetObjectMetadataId: 'company-id', + }); + const companyNameField = createMockField({ + id: 'field-3', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-1', 'field-2'], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-3'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + nameField, + companyRelationField, + companyNameField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + companyObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions([ + 'person-id', + 'company-id', + ]), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: 1, + }); + + expect(result).toEqual({ + name: true, + companyId: true, // join column for MANY_TO_ONE + company: { + name: true, + }, + }); + }); + + it('should respect restricted fields in related objects', () => { + const companyRelationField = createMockField({ + id: 'field-1', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'person-id', + settings: { + relationType: RelationType.ONE_TO_MANY, + }, + relationTargetObjectMetadataId: 'company-id', + }); + const companyNameField = createMockField({ + id: 'field-2', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + const companySecretField = createMockField({ + id: 'field-3', + name: 'secret', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-1'], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-2', 'field-3'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + companyRelationField, + companyNameField, + companySecretField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + companyObject, + ]); + + const objectsPermissions = createObjectsPermissions([ + 'person-id', + 'company-id', + ]); + + objectsPermissions['company-id'].restrictedFields = { + 'field-3': { canRead: false, canUpdate: false }, + }; + + const result = handler.computeFromDepth({ + objectsPermissions, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: 1, + }); + + expect(result).toEqual({ + company: { + name: true, + // secret field is excluded due to restrictions + }, + }); + }); + + it('should not include relation if all fields are restricted', () => { + const companyRelationField = createMockField({ + id: 'field-1', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'person-id', + settings: { + relationType: RelationType.ONE_TO_MANY, + }, + relationTargetObjectMetadataId: 'company-id', + }); + const companyNameField = createMockField({ + id: 'field-2', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-1'], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-2'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + companyRelationField, + companyNameField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + companyObject, + ]); + + const objectsPermissions = createObjectsPermissions([ + 'person-id', + 'company-id', + ]); + + objectsPermissions['company-id'].restrictedFields = { + 'field-2': { canRead: false, canUpdate: false }, + }; + + const result = handler.computeFromDepth({ + objectsPermissions, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: 1, + }); + + // ONE_TO_MANY relations are included as regular boolean fields + expect(result).toEqual({ + company: true, + }); + }); + + it('should handle nested relations up to MAX_DEPTH', () => { + const personCompanyRelation = createMockField({ + id: 'field-1', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'person-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + relationTargetObjectMetadataId: 'company-id', + }); + const companyNameField = createMockField({ + id: 'field-2', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + const companyPeopleRelation = createMockField({ + id: 'field-3', + name: 'people', + type: FieldMetadataType.RELATION, + objectMetadataId: 'company-id', + settings: { + relationType: RelationType.ONE_TO_MANY, + }, + relationTargetObjectMetadataId: 'person-id', + }); + const personNameField = createMockField({ + id: 'field-4', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-1', 'field-4'], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-2', 'field-3'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + personCompanyRelation, + companyNameField, + companyPeopleRelation, + personNameField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + companyObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions([ + 'person-id', + 'company-id', + ]), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: MAX_DEPTH, + }); + + expect(result).toEqual({ + name: true, + company: { + name: true, + people: { + name: true, + company: true, // Nested relation at depth 2 shows as boolean + }, + }, + }); + }); + + it('should only return label identifier fields when flag is true', () => { + const idField = createMockField({ + id: 'field-id', + name: 'id', + type: FieldMetadataType.UUID, + objectMetadataId: 'person-id', + }); + const nameField = createMockField({ + id: 'field-1', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + const emailField = createMockField({ + id: 'field-2', + name: 'email', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-id', 'field-1', 'field-2'], + labelIdentifierFieldMetadataId: 'field-1', + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + idField, + nameField, + emailField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + personObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions(['person-id']), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: personObject, + depth: undefined, + onlyUseLabelIdentifierFieldsInRelations: true, + }); + + // onlyUseLabelIdentifierFields only applies to nested relations, not base fields + expect(result).toEqual({ + id: true, + name: true, + email: true, + }); + }); + + it('should handle activity target objects (noteTarget)', () => { + const noteRelationField = createMockField({ + id: 'field-1', + name: 'note', + type: FieldMetadataType.RELATION, + objectMetadataId: 'noteTarget-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + relationTargetObjectMetadataId: 'note-id', + }); + const targetRelationField = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'noteTarget-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + relationTargetObjectMetadataId: 'company-id', + }); + const noteNameField = createMockField({ + id: 'field-3', + name: 'title', + type: FieldMetadataType.TEXT, + objectMetadataId: 'note-id', + }); + const companyNameField = createMockField({ + id: 'field-4', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + + const noteTargetObject = createMockObjectMetadata({ + id: 'noteTarget-id', + nameSingular: 'noteTarget', + fieldIds: ['field-1', 'field-2'], + }); + const noteObject = createMockObjectMetadata({ + id: 'note-id', + nameSingular: 'note', + fieldIds: ['field-3'], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-4'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + noteRelationField, + targetRelationField, + noteNameField, + companyNameField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + noteTargetObject, + noteObject, + companyObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions([ + 'noteTarget-id', + 'note-id', + 'company-id', + ]), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: noteTargetObject, + depth: 1, + }); + + // For noteTarget, only note and task relations are included + // but company is a MANY_TO_ONE so it's included as a boolean + expect(result).toEqual({ + note: { + title: true, + }, + company: { + name: true, + }, + }); + }); + + it('should handle junction table relations with depth', () => { + const junctionRelation1 = createMockField({ + id: 'field-1', + name: 'person', + type: FieldMetadataType.RELATION, + objectMetadataId: 'junction-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'personId', + }, + relationTargetObjectMetadataId: 'person-id', + }); + const junctionRelation2 = createMockField({ + id: 'field-2', + name: 'company', + type: FieldMetadataType.RELATION, + objectMetadataId: 'junction-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + relationTargetObjectMetadataId: 'company-id', + }); + const companyJunctionRelation = createMockField({ + id: 'field-3', + name: 'personCompanies', + type: FieldMetadataType.RELATION, + objectMetadataId: 'company-id', + settings: { + relationType: RelationType.ONE_TO_MANY, + junctionTargetFieldId: 'field-2', + }, + relationTargetObjectMetadataId: 'junction-id', + }); + const companyNameField = createMockField({ + id: 'field-4', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'company-id', + }); + const personNameField = createMockField({ + id: 'field-5', + name: 'name', + type: FieldMetadataType.TEXT, + objectMetadataId: 'person-id', + }); + + const companyObject = createMockObjectMetadata({ + id: 'company-id', + nameSingular: 'company', + fieldIds: ['field-3', 'field-4'], + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + nameSingular: 'personCompany', + fieldIds: ['field-1', 'field-2'], + }); + const personObject = createMockObjectMetadata({ + id: 'person-id', + nameSingular: 'person', + fieldIds: ['field-5'], + }); + + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + junctionRelation1, + junctionRelation2, + companyJunctionRelation, + companyNameField, + personNameField, + ]); + const flatObjectMetadataMaps = buildFlatObjectMetadataMaps([ + companyObject, + junctionObject, + personObject, + ]); + + const result = handler.computeFromDepth({ + objectsPermissions: createObjectsPermissions([ + 'company-id', + 'junction-id', + 'person-id', + ]), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatObjectMetadata: companyObject, + depth: MAX_DEPTH, + }); + + expect(result).toEqual({ + name: true, + personCompanies: { + personId: true, + companyId: true, + person: { + name: true, + }, + company: { + name: true, + personCompanies: true, // Circular reference shows as boolean + }, + }, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts index 0e97295388..511b3f185d 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts @@ -1,3 +1,3 @@ -import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler'; +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; -export const restToCommonArgsHandlers = [RestToCommonSelectedFieldsHandler]; +export const restToCommonArgsHandlers = [CommonSelectFieldsHelper]; diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/__tests__/get-all-selectable-fields.util.spec.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/__tests__/get-all-selectable-fields.util.spec.ts deleted file mode 100644 index fa92fe7b7f..0000000000 --- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/__tests__/get-all-selectable-fields.util.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - -import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util'; -import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; - -describe('getAllSelectableFields', () => { - const createMockField = ( - overrides: Partial & { - id: string; - name: string; - type: FieldMetadataType; - }, - ): FlatFieldMetadata => - ({ - objectMetadataId: 'object-id', - workspaceId: 'workspace-id', - isNullable: true, - isLabelSyncedWithName: true, - createdAt: new Date(), - updatedAt: new Date(), - universalIdentifier: overrides.id, - viewFieldIds: [], - viewFilterIds: [], - kanbanAggregateOperationViewIds: [], - calendarViewIds: [], - applicationId: null, - label: overrides.name, - ...overrides, - }) as FlatFieldMetadata; - - const buildFlatFieldMetadataMaps = ( - fields: FlatFieldMetadata[], - ): FlatEntityMaps => ({ - byUniversalIdentifier: fields.reduce( - (acc, field) => { - acc[field.universalIdentifier] = field; - - return acc; - }, - {} as Record, - ), - universalIdentifierById: fields.reduce( - (acc, field) => { - acc[field.id] = field.universalIdentifier; - - return acc; - }, - {} as Record, - ), - universalIdentifiersByApplicationId: {}, - }); - - const buildFlatObjectMetadata = (fieldIds: string[]): FlatObjectMetadata => - ({ - id: 'object-id', - workspaceId: 'workspace-id', - nameSingular: 'testObject', - namePlural: 'testObjects', - labelSingular: 'Test Object', - labelPlural: 'Test Objects', - isCustom: false, - isRemote: false, - isActive: true, - isSystem: false, - createdAt: new Date(), - updatedAt: new Date(), - universalIdentifier: 'object-id', - fieldIds: fieldIds, - indexMetadataIds: [], - viewIds: [], - applicationId: null, - }) as unknown as FlatObjectMetadata; - - it('should return all fields as selectable when no restrictions', () => { - const field1 = createMockField({ - id: 'field-1', - name: 'name', - type: FieldMetadataType.TEXT, - }); - const field2 = createMockField({ - id: 'field-2', - name: 'email', - type: FieldMetadataType.TEXT, - }); - const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); - const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); - - const result = getAllSelectableFields({ - restrictedFields: {}, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - expect(result).toEqual({ - name: true, - email: true, - }); - }); - - it('should not return restricted fields', () => { - const field1 = createMockField({ - id: 'field-1', - name: 'name', - type: FieldMetadataType.TEXT, - }); - const field2 = createMockField({ - id: 'field-2', - name: 'email', - type: FieldMetadataType.TEXT, - }); - const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); - const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); - - const result = getAllSelectableFields({ - restrictedFields: { - 'field-2': { canRead: false }, - }, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - expect(result).toEqual({ - name: true, - }); - }); - - it('should create nested objects for composite fields', () => { - const field1 = createMockField({ - id: 'field-1', - name: 'name', - type: FieldMetadataType.TEXT, - }); - const field2 = createMockField({ - id: 'field-2', - name: 'fullName', - type: FieldMetadataType.FULL_NAME, - }); - const field3 = createMockField({ - id: 'field-3', - name: 'domainName', - type: FieldMetadataType.LINKS, - }); - const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ - field1, - field2, - field3, - ]); - const flatObjectMetadata = buildFlatObjectMetadata([ - 'field-1', - 'field-2', - 'field-3', - ]); - - const result = getAllSelectableFields({ - restrictedFields: {}, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - expect(result).toEqual({ - name: true, - fullName: { - firstName: true, - lastName: true, - }, - domainName: { - primaryLinkLabel: true, - primaryLinkUrl: true, - secondaryLinks: true, - }, - }); - }); - - it('should restrict all sub-fields when composite field is restricted', () => { - const field1 = createMockField({ - id: 'field-1', - name: 'name', - type: FieldMetadataType.TEXT, - }); - const field2 = createMockField({ - id: 'field-2', - name: 'fullName', - type: FieldMetadataType.FULL_NAME, - }); - const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]); - const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']); - - const result = getAllSelectableFields({ - restrictedFields: { - 'field-2': { canRead: false }, - }, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - expect(result).toEqual({ - name: true, - }); - }); - - it('should handle mixed regular and composite fields with restrictions', () => { - const field1 = createMockField({ - id: 'field-1', - name: 'name', - type: FieldMetadataType.TEXT, - }); - const field2 = createMockField({ - id: 'field-2', - name: 'email', - type: FieldMetadataType.TEXT, - }); - const field3 = createMockField({ - id: 'field-3', - name: 'fullName', - type: FieldMetadataType.FULL_NAME, - }); - const field4 = createMockField({ - id: 'field-4', - name: 'address', - type: FieldMetadataType.ADDRESS, - }); - const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ - field1, - field2, - field3, - field4, - ]); - const flatObjectMetadata = buildFlatObjectMetadata([ - 'field-1', - 'field-2', - 'field-3', - 'field-4', - ]); - - const result = getAllSelectableFields({ - restrictedFields: { - 'field-2': { canRead: false }, - 'field-4': { canRead: false }, - }, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - expect(result).toEqual({ - name: true, - fullName: { - firstName: true, - lastName: true, - }, - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts index 3ba21f46a6..7c1fb819e6 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts @@ -3,9 +3,9 @@ import { Injectable } from '@nestjs/common'; import { type ObjectsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util'; import { type CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; -import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard'; import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard'; @@ -16,8 +16,8 @@ import { RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service.ts index 7991bf31b5..da07c8cb7f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service.ts @@ -7,10 +7,9 @@ import { IsNull, Not, Repository } from 'typeorm'; import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; +import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map'; import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; -export type UserWorkspaceRoleMap = Record; - @Injectable() @WorkspaceCache('userWorkspaceRoleMap') export class WorkspaceUserWorkspaceRoleMapCacheService extends WorkspaceCacheProvider { diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/types/user-workspace-role-map.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/types/user-workspace-role-map.ts new file mode 100644 index 0000000000..ea1b5684f6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/types/user-workspace-role-map.ts @@ -0,0 +1 @@ +export type UserWorkspaceRoleMap = Record; diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts index d907bcb9b9..dd8808f14f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts @@ -5,6 +5,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map'; import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type'; import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type'; import { type WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; @@ -18,7 +19,7 @@ export interface WorkspaceInternalContext { flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; objectIdByNameSingular: Record; featureFlagsMap: Record; - userWorkspaceRoleMap: Record; + userWorkspaceRoleMap: UserWorkspaceRoleMap; eventEmitterService: WorkspaceEventEmitter; coreDataSource: DataSource; } diff --git a/packages/twenty-server/src/engine/twenty-orm/storage/orm-workspace-context.storage.ts b/packages/twenty-server/src/engine/twenty-orm/storage/orm-workspace-context.storage.ts index 6b31f15a41..9691f0830d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/storage/orm-workspace-context.storage.ts +++ b/packages/twenty-server/src/engine/twenty-orm/storage/orm-workspace-context.storage.ts @@ -9,6 +9,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map'; import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type'; import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type'; @@ -21,7 +22,7 @@ export type ORMWorkspaceContext = { featureFlagsMap: Record; permissionsPerRoleId: ObjectsPermissionsByRoleId; entityMetadatas: EntityMetadata[]; - userWorkspaceRoleMap: Record; + userWorkspaceRoleMap: UserWorkspaceRoleMap; flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps; flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; }; diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 0e27db6c0d..c88b8a8725 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -7,7 +7,7 @@ import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/ import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; import { type FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service'; +import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map'; import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type'; import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type'; diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts index 23154a59a2..4aaff3a8ea 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts @@ -5,7 +5,10 @@ import { type RecordGqlOperationFilter, } from 'twenty-shared/types'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; import { COMPANY_FLAT_FIELDS_MOCK } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/company-flat-fields.mock'; @@ -16,6 +19,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object import { EventStreamService } from 'src/engine/subscriptions/event-stream.service'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service'; @@ -79,6 +83,19 @@ describe('WorkspaceEventEmitterService', () => { let mockWorkspaceCacheService: { getOrRecompute: jest.Mock; }; + let mockProcessNestedRelationsHelper: jest.Mocked< + Pick + >; + let mockWorkspaceManyOrAllFlatEntityMapsCacheService: jest.Mocked< + Pick< + WorkspaceManyOrAllFlatEntityMapsCacheService, + 'getOrRecomputeManyOrAllFlatEntityMaps' + > + >; + + let mockGlobalWorkspaceOrmManager: jest.Mocked< + Pick + >; const workspaceId = COMPANY_FLAT_OBJECT_MOCK.workspaceId; const streamChannelId = 'test-stream-channel-id'; @@ -225,6 +242,29 @@ describe('WorkspaceEventEmitterService', () => { getOrRecompute: jest.fn().mockImplementation(createCacheMock()), }; + mockProcessNestedRelationsHelper = { + processNestedRelations: jest.fn(), + }; + + mockWorkspaceManyOrAllFlatEntityMapsCacheService = { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ + flatFieldMetadataMaps: mockFlatFieldMetadataMaps, + flatObjectMetadataMaps: { + byId: { + [companyObjectMetadata.id]: companyObjectMetadata, + }, + idByUniversalIdentifier: {}, + universalIdentifiersByApplicationId: {}, + }, + } as never), + }; + + mockGlobalWorkspaceOrmManager = { + getGlobalWorkspaceDataSourceReplica: jest.fn().mockResolvedValue({ + getRepository: jest.fn(), + }), + }; + (buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({}); ( isRecordMatchingRLSRowLevelPermissionPredicate as jest.Mock @@ -245,6 +285,22 @@ describe('WorkspaceEventEmitterService', () => { provide: WorkspaceCacheService, useValue: mockWorkspaceCacheService, }, + { + provide: ProcessNestedRelationsHelper, + useValue: mockProcessNestedRelationsHelper, + }, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: mockWorkspaceManyOrAllFlatEntityMapsCacheService, + }, + { + provide: GlobalWorkspaceOrmManager, + useValue: mockGlobalWorkspaceOrmManager, + }, + { + provide: CommonSelectFieldsHelper, + useValue: new CommonSelectFieldsHelper(), + }, ], }).compile(); @@ -1154,5 +1210,262 @@ describe('WorkspaceEventEmitterService', () => { ); }); }); + + describe('nested relations enrichment', () => { + it('should enrich events with nested relations when publishing', async () => { + const recordAfter = { id: 'record-1', name: 'Test Company' }; + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + properties: { + after: recordAfter, + }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockWorkspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps, + ).toHaveBeenCalledWith({ + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }); + + expect( + mockGlobalWorkspaceOrmManager.getGlobalWorkspaceDataSourceReplica, + ).toHaveBeenCalled(); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).toHaveBeenCalledWith( + expect.objectContaining({ + parentObjectMetadataItem: companyObjectMetadata, + parentObjectRecords: expect.arrayContaining([recordAfter]), + authContext: expect.objectContaining({ + userWorkspaceId, + userId: 'test-user-id', + }), + workspaceDataSource: expect.objectContaining({ + getRepository: expect.any(Function), + }), + rolePermissionConfig: expect.objectContaining({ + intersectionOf: [roleId], + }), + }), + ); + }); + + it('should include both before and after records when enriching update events', async () => { + const recordBefore = { id: 'record-1', name: 'Old Name' }; + const recordAfter = { id: 'record-1', name: 'New Name' }; + + const eventBatch: WorkspaceEventBatch = { + name: 'company.updated', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + properties: { + before: recordBefore, + after: recordAfter, + updatedFields: ['name'], + diff: { name: { before: 'Old Name', after: 'New Name' } }, + }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).toHaveBeenCalledWith( + expect.objectContaining({ + parentObjectRecords: expect.arrayContaining([ + recordBefore, + recordAfter, + ]), + }), + ); + }); + + it('should include only before records when enriching delete events', async () => { + const recordBefore = { id: 'record-1', name: 'Deleted Company' }; + + const streamDataWithFilter: EventStreamData = { + ...mockStreamData, + queries: { + 'query-1': { + objectNameSingular: 'company', + variables: {}, + }, + }, + }; + + mockEventStreamService.getStreamsData.mockResolvedValue( + new Map([[streamChannelId, streamDataWithFilter]]) as Map< + string, + EventStreamData | undefined + >, + ); + + const eventBatch: WorkspaceEventBatch = { + name: 'company.deleted', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + properties: { + before: recordBefore, + }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).toHaveBeenCalledWith( + expect.objectContaining({ + parentObjectRecords: expect.arrayContaining([recordBefore]), + }), + ); + }); + + it('should enrich multiple records from batch events', async () => { + const record1 = { id: 'record-1', name: 'Company 1' }; + const record2 = { id: 'record-2', name: 'Company 2' }; + const record3 = { id: 'record-3', name: 'Company 3' }; + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + recordId: 'record-1', + properties: { after: record1 }, + }), + createMockEvent({ + recordId: 'record-2', + properties: { after: record2 }, + }), + createMockEvent({ + recordId: 'record-3', + properties: { after: record3 }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).toHaveBeenCalledWith( + expect.objectContaining({ + parentObjectRecords: expect.arrayContaining([ + record1, + record2, + record3, + ]), + }), + ); + }); + + it('should not call processNestedRelations when no events match', async () => { + const permissionsWithoutRead: ObjectsPermissionsByRoleId = { + [roleId]: { + [companyObjectMetadata.id]: { + canReadObjectRecords: false, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }, + }, + }; + + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ rolesPermissions: permissionsWithoutRead }), + ); + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [createMockEvent()], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).not.toHaveBeenCalled(); + }); + + it('should pass correct role permission config when enriching events', async () => { + const customRoleId = 'custom-role-id'; + const customUserWorkspaceRoleMap = { + [userWorkspaceId]: customRoleId, + }; + + const customRolesPermissions: ObjectsPermissionsByRoleId = { + [customRoleId]: { + [companyObjectMetadata.id]: { + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }, + }, + }; + + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ + userWorkspaceRoleMap: customUserWorkspaceRoleMap, + rolesPermissions: customRolesPermissions, + }), + ); + + const recordAfter = { id: 'record-1', name: 'Test Company' }; + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + properties: { + after: recordAfter, + }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect( + mockProcessNestedRelationsHelper.processNestedRelations, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rolePermissionConfig: { + intersectionOf: [customRoleId], + }, + }), + ); + }); + }); }); }); diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.module.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.module.ts index cc5b08bf4c..f4f67e3b60 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.module.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.module.ts @@ -1,5 +1,9 @@ import { Global, Module } from '@nestjs/common'; +import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; @@ -8,11 +12,18 @@ import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter @Global() @Module({ - imports: [SubscriptionsModule, WorkspaceCacheModule], + imports: [ + SubscriptionsModule, + WorkspaceCacheModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], providers: [ WorkspaceEventEmitter, WorkspaceEventEmitterService, WorkspaceEventEmitterResolver, + ProcessNestedRelationsHelper, + ProcessNestedRelationsV2Helper, + CommonSelectFieldsHelper, ], exports: [WorkspaceEventEmitter, WorkspaceEventEmitterService], }) diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts index e445b5e278..b08cbaac26 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts @@ -1,28 +1,39 @@ import { Injectable } from '@nestjs/common'; +import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants'; import { type ObjectRecordEvent } from 'twenty-shared/database-events'; import { + Nullable, + ObjectRecord, type ObjectsPermissionsByRoleId, type RecordGqlOperationFilter, type RestrictedFieldsPermissions, } from 'twenty-shared/types'; import { combineFilters, isDefined } from 'twenty-shared/utils'; +import { FindOptionsRelations, ObjectLiteral } from 'typeorm'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; +import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; -import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map'; import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type'; import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type'; +import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event'; import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum'; import { EventStreamService } from 'src/engine/subscriptions/event-stream.service'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type'; import { ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util'; import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -35,6 +46,10 @@ export class WorkspaceEventEmitterService { private readonly subscriptionService: SubscriptionService, private readonly eventStreamService: EventStreamService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly processNestedRelationsHelper: ProcessNestedRelationsHelper, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper, ) {} async publish( @@ -214,6 +229,15 @@ export class WorkspaceEventEmitterService { } if (matchedEvents.length > 0) { + await this.enrichEventBatchWithNestedRelations({ + objectMetadata: workspaceEventBatch.objectMetadata, + events: matchedEvents.map((e) => e.event), + streamData, + permissionsContext, + workspaceId: workspaceEventBatch.workspaceId, + roleId, + }); + await this.subscriptionService.publishToEventStream({ workspaceId: workspaceEventBatch.workspaceId, eventStreamChannelId: streamChannelId, @@ -222,6 +246,98 @@ export class WorkspaceEventEmitterService { } } + private async enrichEventBatchWithNestedRelations({ + streamData, + objectMetadata, + events, + workspaceId, + permissionsContext, + roleId, + }: { + streamData: EventStreamData; + objectMetadata: FlatObjectMetadata; + events: ObjectRecordEvent[]; + workspaceId: string; + roleId: string; + permissionsContext: { + flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps; + flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; + flatFieldMetadataMaps: FlatEntityMaps; + userWorkspaceRoleMap: UserWorkspaceRoleMap; + rolesPermissions: ObjectsPermissionsByRoleId; + }; + }) { + const { flatFieldMetadataMaps, flatObjectMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const allRecords: ObjectRecord[] = []; + + for (const event of events) { + if ('before' in event.properties) { + const recordBefore = event.properties.before as Nullable; + + if (isDefined(recordBefore)) { + allRecords.push(recordBefore); + } + } + + if ('after' in event.properties) { + const recordAfter = event.properties.after as Nullable; + + if (isDefined(recordAfter)) { + allRecords.push(recordAfter); + } + } + } + + const rolePermissionConfig: RolePermissionConfig = { + intersectionOf: [roleId], + }; + + const globalWorkspaceDataSource = + await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSourceReplica(); + + const selectedFields = this.commonSelectFieldsHelper.computeFromDepth({ + depth: 1, + flatObjectMetadata: objectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectsPermissions: permissionsContext.rolesPermissions[roleId], + onlyUseLabelIdentifierFieldsInRelations: true, + recurseIntoJunctionTableRelations: true, + }); + + const commonQueryParser = new GraphqlQueryParser( + objectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); + + const selectedFieldsResult = + commonQueryParser.parseSelectedFields(selectedFields); + + await this.processNestedRelationsHelper.processNestedRelations({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + parentObjectMetadataItem: objectMetadata, + parentObjectRecords: allRecords, + authContext: streamData.authContext, + limit: QUERY_MAX_RECORDS_FROM_RELATION, + rolePermissionConfig, + workspaceDataSource: globalWorkspaceDataSource, + relations: selectedFieldsResult.relations as Record< + string, + FindOptionsRelations + >, + selectedFields: selectedFieldsResult.select, + }); + } + private buildSubscriberRLSFilter( subscriberAuthContext: SerializableAuthContext, roleId: string, diff --git a/packages/twenty-shared/src/metadata/check-if-field-is-image-identifier.util.ts b/packages/twenty-shared/src/metadata/check-if-field-is-image-identifier.util.ts new file mode 100644 index 0000000000..bdcf9db046 --- /dev/null +++ b/packages/twenty-shared/src/metadata/check-if-field-is-image-identifier.util.ts @@ -0,0 +1,23 @@ +import { type Nullable } from '@/types'; + +export const checkIfFieldIsImageIdentifier = ( + fieldMetadataItem: { + id: string; + name: string; + }, + objectMetadataItem: { + nameSingular: string; + imageIdentifierFieldMetadataId?: Nullable; + }, +): boolean => { + if ( + objectMetadataItem.nameSingular === 'company' && + fieldMetadataItem.name === 'domainName' + ) { + return true; + } + + return ( + objectMetadataItem.imageIdentifierFieldMetadataId === fieldMetadataItem.id + ); +}; diff --git a/packages/twenty-shared/src/metadata/check-if-field-is-label-identifier.util.ts b/packages/twenty-shared/src/metadata/check-if-field-is-label-identifier.util.ts new file mode 100644 index 0000000000..0f71d1d5d0 --- /dev/null +++ b/packages/twenty-shared/src/metadata/check-if-field-is-label-identifier.util.ts @@ -0,0 +1,13 @@ +import { type Nullable } from '@/types'; +import { isDefined } from '@/utils'; + +export const DEFAULT_LABEL_IDENTIFIER_FIELD_NAME = 'name'; + +export const checkIfFieldIsLabelIdentifier = ( + fieldMetadataItem: { id: string; name: string }, + objectMetadataItem: { labelIdentifierFieldMetadataId?: Nullable }, +): boolean => { + return isDefined(objectMetadataItem.labelIdentifierFieldMetadataId) + ? fieldMetadataItem.id === objectMetadataItem.labelIdentifierFieldMetadataId + : fieldMetadataItem.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME; +}; diff --git a/packages/twenty-shared/src/metadata/index.ts b/packages/twenty-shared/src/metadata/index.ts index 81e83e4d1b..980edc99ef 100644 --- a/packages/twenty-shared/src/metadata/index.ts +++ b/packages/twenty-shared/src/metadata/index.ts @@ -10,6 +10,11 @@ export { addCustomSuffixIfIsReserved } from './add-custom-suffix-if-reserved.util'; export { ALL_METADATA_NAME } from './all-metadata-name.constant'; export type { AllMetadataName } from './all-metadata-name.type'; +export { checkIfFieldIsImageIdentifier } from './check-if-field-is-image-identifier.util'; +export { + DEFAULT_LABEL_IDENTIFIER_FIELD_NAME, + checkIfFieldIsLabelIdentifier, +} from './check-if-field-is-label-identifier.util'; export { computeMetadataNameFromLabel } from './compute-metadata-name-from-label.util'; export type { FailedMetadataValidationError,