From 8938dd637fbd678252a9eb90e80f34bcedaba7d3 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 17 Feb 2026 14:57:26 +0100 Subject: [PATCH] Added relations to SSE events (#17683) Fixes https://github.com/twentyhq/core-team-issues/issues/2192 This PR implements what is necessary to re-create the query that we build on the frontend to obtain the returned object record from a mutation, but on the backend, which was only partially implemented for REST API. Usually we want to have relations with only their id and label identifier field to have lighter payloads. In the event we only had depth 0 fields, with this PR we have all events with depth 1 relations. We have depth 2 for many-to-many cases, like updateOne or updateMany result : - Junction tables - Activity target tables --- .../tests/create-record.spec.ts | 8 +- .../computeOptimisticRecordFromInput.test.ts | 42 -- .../utils/computeOptimisticRecordFromInput.ts | 12 - .../hooks/useTriggerEventStreamCreation.ts | 107 ++- .../common-selected-fields.handler.ts | 161 ---- .../process-nested-relations-v2.helper.ts | 0 .../process-nested-relations.helper.ts | 2 +- .../common-base-query-runner.service.ts | 2 +- .../common-select-fields-helper.ts} | 54 +- .../get-all-selectable-fields.util.spec.ts | 645 ++++++++++++++++ .../utils/get-all-selectable-fields.util.ts | 26 + .../get-is-flat-field-a-join-column.util.ts | 19 + ...is-flat-field-a-junction-relation-field.ts | 22 + .../utils/get-should-recurse-into-relation.ts | 31 + .../api/common/core-common-api.module.ts | 4 +- .../graphql-query-runner.module.ts | 4 +- .../services/group-by-with-records.service.ts | 2 +- .../core/handlers/rest-api-base.handler.ts | 6 +- .../__tests__/selected-fields-handler.spec.ts | 692 ++++++++++++++++++ .../rest-to-common-args-handlers.ts | 4 +- .../get-all-selectable-fields.util.spec.ts | 254 ------- .../common-api-context-builder.service.ts | 4 +- ...e-user-workspace-role-map-cache.service.ts | 3 +- .../types/user-workspace-role-map.ts | 1 + .../workspace-internal-context.interface.ts | 3 +- .../storage/orm-workspace-context.storage.ts | 3 +- .../types/workspace-cache-key.type.ts | 2 +- .../workspace-event-emitter.service.spec.ts | 313 ++++++++ .../workspace-event-emitter.module.ts | 13 +- .../workspace-event-emitter.service.ts | 118 ++- ...check-if-field-is-image-identifier.util.ts | 23 + ...check-if-field-is-label-identifier.util.ts | 13 + packages/twenty-shared/src/metadata/index.ts | 5 + 33 files changed, 2041 insertions(+), 557 deletions(-) delete mode 100644 packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts rename packages/twenty-server/src/engine/api/{graphql/graphql-query-runner/helpers => common/common-nested-relations-processor}/process-nested-relations-v2.helper.ts (100%) rename packages/twenty-server/src/engine/api/{graphql/graphql-query-runner/helpers => common/common-nested-relations-processor}/process-nested-relations.helper.ts (94%) rename packages/twenty-server/src/engine/api/{rest/core/rest-to-common-args-handlers/selected-fields-handler.ts => common/common-select-fields/common-select-fields-helper.ts} (66%) create mode 100644 packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts rename packages/twenty-server/src/engine/api/{rest/core/rest-to-common-args-handlers => common/common-select-fields}/utils/get-all-selectable-fields.util.ts (78%) create mode 100644 packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-should-recurse-into-relation.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/utils/__tests__/get-all-selectable-fields.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/role-target/types/user-workspace-role-map.ts create mode 100644 packages/twenty-shared/src/metadata/check-if-field-is-image-identifier.util.ts create mode 100644 packages/twenty-shared/src/metadata/check-if-field-is-label-identifier.util.ts 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,