From 2514cab86040098162e13f8ef7c783adbaec17bb Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 10 Jun 2026 16:48:24 +0200 Subject: [PATCH] fix(server): include relation join column names in updatedFields of update events (#21405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Since #21052, update-event diffs are keyed by the relation field name (e.g. `company`) instead of the join column name (e.g. `companyId`). `updatedFields` is derived from the diff keys, so any **workflow database-event trigger** (or webhook) configured with a field filter on a relation join column **silently stopped firing** — no run is created at all. We hit this in production: a `cloudWorkspace.updated` trigger filtered on `twentyContactId` stopped creating runs the same day #21052 was deployed. Updating the record's relation produced `updatedFields: ["twentyContact"]`, which no longer matches the stored settings `fields: ["twentyContactId"]` in `WorkflowDatabaseEventTriggerListener.shouldTriggerJob`. ## Solution Keep the diff keyed by relation field name (the timeline rendering from #21052 relies on it — adding both keys to the diff would display relation changes twice), but expose **both** the relation field name and its join column name in `updatedFields`: - New `computeUpdatedFieldsFromDiff()` in `object-record-changed-values.ts`: expands MANY_TO_ONE relation diff keys with their join column name. - Used in `formatTwentyOrmEventToDatabaseBatchEvent` for UPDATED/DELETED/RESTORED and UPSERTED events instead of `Object.keys(diff)`. This restores matching for pre-existing trigger/webhook configurations (join column names) while keeping configurations using relation field names working. ## Test plan - [x] Unit tests: relation diff keyed by relation name; `updatedFields` contains both `company` and `companyId` - [x] End-to-end util test on UPDATED event: `updatedFields: ['company', 'companyId']`, diff keyed by `company` - [x] Downstream consumer specs pass (workflow trigger listener, webhooks, subscriptions, logic-function triggers) - [x] Verified against the production workspace that a `twentyContactId` update currently produces no workflow run with the old behavior --- .../object-record-changed-values.spec.ts | 113 +++++++++++++++++- .../utils/object-record-changed-values.ts | 33 +++++ ...event-to-database-batch-event.util.spec.ts | 59 ++++++++- ...-orm-event-to-database-batch-event.util.ts | 17 ++- 4 files changed, 216 insertions(+), 6 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts index e3045e4c2e..7f182c6414 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts @@ -1,6 +1,9 @@ -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; -import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; +import { + computeUpdatedFieldsFromDiff, + objectRecordChangedValues, +} from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; 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'; @@ -198,4 +201,110 @@ describe('objectRecordChangedValues', () => { }); expect(result).not.toHaveProperty('position'); }); + + describe('with a MANY_TO_ONE relation field', () => { + const relationFieldId = 'company-field-id'; + const relationUniversalId = 'company-universal-id'; + + const objectMetadataWithRelation: FlatObjectMetadata = { + ...mockObjectMetadata, + fieldIds: [relationFieldId], + }; + + const flatFieldMetadataMapsWithRelation: FlatEntityMaps = + { + byUniversalIdentifier: { + [relationUniversalId]: { + id: relationFieldId, + name: 'company', + type: FieldMetadataType.RELATION, + universalIdentifier: relationUniversalId, + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + } as FlatFieldMetadata, + }, + universalIdentifierById: { + [relationFieldId]: relationUniversalId, + }, + universalIdentifiersByApplicationId: {}, + }; + + it('records join column changes under the relation field name', () => { + const oldRecord = { + id: '74316f58-29b0-4a6a-b8fa-d2b506d5516p', + companyId: 'old-company-id', + }; + const newRecord = { + id: '74316f58-29b0-4a6a-b8fa-d2b506d5516p', + companyId: 'new-company-id', + }; + + const result = objectRecordChangedValues( + oldRecord, + newRecord, + objectMetadataWithRelation, + flatFieldMetadataMapsWithRelation, + ); + + expect(result).toEqual({ + company: { + before: { id: 'old-company-id' }, + after: { id: 'new-company-id' }, + }, + }); + }); + + it('computes updatedFields with both relation field name and join column name', () => { + const diff = { + company: { + before: { id: 'old-company-id' }, + after: { id: 'new-company-id' }, + }, + name: { before: 'Original', after: 'Updated' }, + }; + + const updatedFields = computeUpdatedFieldsFromDiff( + diff, + objectMetadataWithRelation, + flatFieldMetadataMapsWithRelation, + ); + + expect(updatedFields).toEqual(['company', 'companyId', 'name']); + }); + + it('uses the canonical computed join column name even when settings diverge', () => { + const flatFieldMetadataMapsWithDivergentJoinColumn: FlatEntityMaps = + { + ...flatFieldMetadataMapsWithRelation, + byUniversalIdentifier: { + [relationUniversalId]: { + ...flatFieldMetadataMapsWithRelation.byUniversalIdentifier[ + relationUniversalId + ], + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'legacyCompanyId', + }, + } as FlatFieldMetadata, + }, + }; + + const diff = { + company: { + before: { id: 'old-company-id' }, + after: { id: 'new-company-id' }, + }, + }; + + const updatedFields = computeUpdatedFieldsFromDiff( + diff, + objectMetadataWithRelation, + flatFieldMetadataMapsWithDivergentJoinColumn, + ); + + expect(updatedFields).toEqual(['company', 'companyId']); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts index e5f59e4b27..37f25b12bf 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts @@ -44,6 +44,39 @@ const getJoinColumnNameForRelationField = ( ); }; +export const computeUpdatedFieldsFromDiff = ( + diff: Record, + objectMetadataItem: FlatObjectMetadata, + flatFieldMetadataMaps: FlatEntityMaps, +): string[] => { + const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + objectMetadataItem, + ); + + return Object.keys(diff).flatMap((diffKey) => { + const fieldId = fieldIdByName[diffKey]; + + if (!isDefined(fieldId)) { + return [diffKey]; + } + + const field = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (isDefined(field) && isManyToOneRelationField(field)) { + return [ + diffKey, + computeMorphOrRelationFieldJoinColumnName({ name: field.name }), + ]; + } + + return [diffKey]; + }); +}; + export const objectRecordChangedValues = ( oldRecord: Partial, newRecord: Partial, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts index 99bd7ade01..fdc5bdfb54 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts @@ -1,5 +1,5 @@ import { type ObjectRecordUpdateEvent } from 'twenty-shared/database-events'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; @@ -181,5 +181,62 @@ describe('formatTwentyOrmEventToDatabaseBatchEvent', () => { expect(updateEvent2.properties?.before?.name).toBe('Jane Doe'); expect(updateEvent2.properties?.after?.name).toBe('Jane Doe Updated'); }); + + it('should include both relation field name and join column name in updatedFields', () => { + const companyField = createMockField({ + id: 'company-id', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + } as Parameters[0]); + + const flatFieldMetadataMapsWithRelation: FlatEntityMaps = + { + byUniversalIdentifier: { + 'name-id': nameField, + 'company-id': companyField, + }, + universalIdentifierById: { + 'name-id': 'name-id', + 'company-id': 'company-id', + }, + universalIdentifiersByApplicationId: {}, + }; + + const flatObjectMetadataWithRelation = { + ...flatObjectMetadata, + fieldIds: ['name-id', 'company-id'], + } as FlatObjectMetadata; + + const result = formatTwentyOrmEventToDatabaseBatchEvent({ + action: DatabaseEventAction.UPDATED, + objectMetadataItem: flatObjectMetadataWithRelation, + flatFieldMetadataMaps: flatFieldMetadataMapsWithRelation, + workspaceId: mockWorkspaceId, + authContext: mockAuthContext, + recordsAfter: [{ id: 'record-1', companyId: 'new-company-id' }], + recordsBefore: [{ id: 'record-1', companyId: 'old-company-id' }], + }); + + const updateEvent = result?.events[0] as ObjectRecordUpdateEvent<{ + id: string; + companyId: string; + }>; + + expect(updateEvent.properties?.updatedFields).toEqual([ + 'company', + 'companyId', + ]); + expect(updateEvent.properties?.diff).toEqual({ + company: { + before: { id: 'old-company-id' }, + after: { id: 'new-company-id' }, + }, + }); + }); }); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts index 080f5b6061..6a0c1b7580 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts @@ -17,7 +17,10 @@ import type { ObjectLiteral } from 'typeorm'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; -import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; +import { + computeUpdatedFieldsFromDiff, + objectRecordChangedValues, +} from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; 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'; @@ -128,7 +131,11 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < flatFieldMetadataMaps, ) as Partial>; - const updatedFields = Object.keys(diff); + const updatedFields = computeUpdatedFieldsFromDiff( + diff, + objectMetadataItem, + flatFieldMetadataMaps, + ); if (updatedFields.length === 0) { return; @@ -226,7 +233,11 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < flatFieldMetadataMaps, ) as Partial>; - updatedFields = Object.keys(diff); + updatedFields = computeUpdatedFieldsFromDiff( + diff, + objectMetadataItem, + flatFieldMetadataMaps, + ); event.properties = { after: recordAfter,