Compare commits

...
Author SHA1 Message Date
sonarly-bot f75e7b3a4d fix: include relation field diffs in timeline activities
https://sonarly.com/issue/41112?type=bug

Timeline updates omit MANY_TO_ONE relation changes, and relation-only updates can produce no visible activity entry at all. This breaks audit-trail completeness for common CRM edits.

Fix: Implemented a backend fix so relation changes are included in timeline update diffs and relation-only updates are no longer dropped as empty diffs.

What changed:
1) `packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts`
- Stopped excluding `FieldMetadataType.RELATION` from diff generation.
- Used both maps from `buildFieldMapsFromFlatObjectMetadata`:
  - `fieldIdByName`
  - `fieldIdByJoinColumnName`
- Added normalization of diff keys to metadata field names (`diffKey = field?.name ?? key`), so MANY_TO_ONE join column updates like `companyId` are emitted as `company`.
- Kept POSITION filtering intact.

This fixes both:
- missing relation diffs in update activities
- relation-only updates being filtered out downstream as empty diffs
and avoids frontend metadata lookup failures due to `...Id` diff keys.

2) `packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts`
- Added test: includes relation-field changes in diff.
- Added test: normalizes MANY_TO_ONE join-column changes (`companyId`) to relation field name (`company`) in diff output.
- Retained existing POSITION-ignore behavior test.

Validation attempts:
- Tried running:
  `npx jest packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts --config=packages/twenty-server/jest.config.mjs`
- Blocked by environment dependency resolution error: `Cannot find module 'file-type'` from Jest config.

Authored by Sonarly by autonomous analysis (run 46864).
2026-05-27 15:10:09 +00:00
2 changed files with 101 additions and 8 deletions
@@ -1,4 +1,4 @@
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 { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -151,6 +151,101 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual(expectedChanges);
});
it('includes changes to RELATION fields', () => {
const relationFieldId = 'company-field-id';
const relationUniversalId = 'company-universal-id';
const objectMetadataWithRelation: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [relationFieldId],
};
const flatFieldMetadataMapsWithRelation: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[relationUniversalId]: {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
universalIdentifier: relationUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[relationFieldId]: relationUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
company: { id: 'company-1' },
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
company: { id: 'company-2' },
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithRelation,
flatFieldMetadataMapsWithRelation,
);
expect(result).toEqual({
company: { before: { id: 'company-1' }, after: { id: 'company-2' } },
});
});
it('normalizes MANY_TO_ONE join column changes to relation field names', () => {
const relationFieldId = 'company-field-id';
const relationUniversalId = 'company-universal-id';
const objectMetadataWithRelation: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [relationFieldId],
};
const flatFieldMetadataMapsWithRelation: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[relationUniversalId]: {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
universalIdentifier: relationUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[relationFieldId]: relationUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
companyId: 'company-1',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
companyId: 'company-2',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithRelation,
flatFieldMetadataMapsWithRelation,
);
expect(result).toEqual({
company: { before: 'company-1', after: 'company-2' },
});
expect(result).not.toHaveProperty('companyId');
});
it('ignores changes to POSITION fields', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
@@ -13,20 +13,19 @@ export const objectRecordChangedValues = (
objectMetadataItem: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadataItem,
);
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(flatFieldMetadataMaps, objectMetadataItem);
return Object.keys(newRecord).reduce(
(acc, key) => {
const fieldId = fieldIdByName[key];
const fieldId = fieldIdByName[key] ?? fieldIdByJoinColumnName[key];
const field = fieldId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
})
: undefined;
const diffKey = field?.name ?? key;
const oldRecordValue = oldRecord[key];
const newRecordValue = newRecord[key];
@@ -34,7 +33,6 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.POSITION
) {
return acc;
@@ -44,7 +42,7 @@ export const objectRecordChangedValues = (
return acc;
}
acc[key] = { before: oldRecordValue, after: newRecordValue };
acc[diffKey] = { before: oldRecordValue, after: newRecordValue };
return acc;
},