From 8aa208fc937ec4949a66fbca4774c0dd49c805f5 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 8 Apr 2026 13:53:55 +0200 Subject: [PATCH] Fix overridable entities logic for SSE (#19433) ## Context SSE metadata events for overridable entities (viewField, viewFieldGroup, pageLayoutWidget, pageLayoutTab) were sending raw flat entity data without resolving overrides into base properties or filtering isActive: false entities. This caused the frontend to display stale/incorrect values (e.g., a hidden viewField still appearing visible). ## Implementation Add a sanitization step in MetadataEventPublisher.enrichMetadataEventBatch that resolves overrides and converts isActive transitions into the appropriate event types (deactivated -> delete, reactivated -> create), matching what GraphQL resolvers already do --- ...cordTableWidgetsViewDataOnDashboardSave.ts | 4 +- .../services/fields-widget-upsert.service.ts | 224 ++++++++--- .../metadata-event-publisher.ts | 3 +- ...verridable-entity-event-batch.util.spec.ts | 378 ++++++++++++++++++ ...ize-overridable-entity-event-batch.util.ts | 116 ++++++ 5 files changed, 670 insertions(+), 55 deletions(-) create mode 100644 packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/sanitize-overridable-entity-event-batch.util.spec.ts create mode 100644 packages/twenty-server/src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave.ts index 3e551de5b7..e6e3fd0c8c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave.ts @@ -186,10 +186,12 @@ export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => { mapRecordFieldToViewFieldWithCurrentAggregateOperation, ); + const existingViewFields = currentView.viewFields ?? []; + const { viewFieldsToCreate, viewFieldsToUpdate } = computeViewFieldsToCreateAndUpdate({ newViewFields, - existingViewFields: currentView.viewFields ?? [], + existingViewFields, viewId, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts index 1b3081ed8c..79970eded4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts @@ -14,8 +14,8 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat 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 { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.util'; import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.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 { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util'; @@ -409,6 +409,72 @@ export class FieldsWidgetUpsertService { continue; } + const existingField = existingViewFields.find( + (field) => field.fieldMetadataId === inputField.fieldMetadataId, + ); + + if (isDefined(existingField)) { + const { + viewFieldGroupUniversalIdentifier: + newViewFieldGroupUniversalIdentifier, + } = resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewField', + foreignKeyValues: { + viewFieldGroupId: inputGroup.id, + }, + flatEntityMaps: { + flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps, + }, + }); + + const shouldOverride = isCallerOverridingEntity({ + callerApplicationUniversalIdentifier: + applicationUniversalIdentifier, + entityApplicationUniversalIdentifier: + existingField.applicationUniversalIdentifier, + workspaceCustomApplicationUniversalIdentifier: + applicationUniversalIdentifier, + }); + + const { overrides, updatedEditableProperties: sanitizedFieldProps } = + sanitizeOverridableEntityInput({ + metadataName: 'viewField', + existingFlatEntity: existingField, + updatedEditableProperties: { + isVisible: inputField.isVisible, + position: inputField.position, + viewFieldGroupId: inputGroup.id, + }, + shouldOverride, + }); + + const updatedField: FlatViewField = { + ...existingField, + ...sanitizedFieldProps, + overrides, + updatedAt: now, + }; + + if (sanitizedFieldProps.viewFieldGroupId !== undefined) { + updatedField.viewFieldGroupUniversalIdentifier = + newViewFieldGroupUniversalIdentifier; + } + + if (isDefined(overrides)) { + updatedField.universalOverrides = + fromViewFieldOverridesToUniversalOverrides({ + overrides, + viewFieldGroupUniversalIdentifierById: + optimisticFlatViewFieldGroupMaps.universalIdentifierById, + }); + } else { + updatedField.universalOverrides = null; + } + + viewFieldsToUpdate.push(updatedField); + continue; + } + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ flatEntityId: inputField.fieldMetadataId, flatEntityMaps: flatFieldMetadataMaps, @@ -612,69 +678,121 @@ export class FieldsWidgetUpsertService { return [updatedField]; }); - const viewFieldsToCreate: FlatViewField[] = inputFields - .filter((inputField) => { - if ( - isDefined(inputField.viewFieldId) || - !isDefined(inputField.fieldMetadataId) - ) { - return false; - } + const viewFieldsToCreate: FlatViewField[] = []; - const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: inputField.fieldMetadataId, - flatEntityMaps: flatFieldMetadataMaps, + for (const inputField of inputFields) { + if ( + isDefined(inputField.viewFieldId) || + !isDefined(inputField.fieldMetadataId) + ) { + continue; + } + + const existingField = existingViewFields.find( + (field) => field.fieldMetadataId === inputField.fieldMetadataId, + ); + + if (isDefined(existingField)) { + const shouldOverride = isCallerOverridingEntity({ + callerApplicationUniversalIdentifier: applicationUniversalIdentifier, + entityApplicationUniversalIdentifier: + existingField.applicationUniversalIdentifier, + workspaceCustomApplicationUniversalIdentifier: + applicationUniversalIdentifier, }); - return ( - isDefined(fieldMetadata) && - isFieldMetadataEligibleForFieldsWidget({ - fieldName: fieldMetadata.name, - fieldType: fieldMetadata.type, - isLabelIdentifierField: - fieldMetadata.id === labelIdentifierFieldMetadataId, - }) - ); - }) - .map((inputField) => { - const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } = - resolveEntityRelationUniversalIdentifiers({ + const { overrides, updatedEditableProperties: sanitizedFieldProps } = + sanitizeOverridableEntityInput({ metadataName: 'viewField', - foreignKeyValues: { - fieldMetadataId: inputField.fieldMetadataId!, - viewId, - }, - flatEntityMaps: { - flatFieldMetadataMaps, - flatViewMaps, + existingFlatEntity: existingField, + updatedEditableProperties: { + isVisible: inputField.isVisible, + position: inputField.position, + viewFieldGroupId: null, }, + shouldOverride, }); - return { - id: v4(), - workspaceId, - applicationId, - universalIdentifier: v4(), - applicationUniversalIdentifier, - fieldMetadataId: inputField.fieldMetadataId!, - fieldMetadataUniversalIdentifier, - viewId, - viewUniversalIdentifier, - viewFieldGroupId: null, - viewFieldGroupUniversalIdentifier: null, - isVisible: inputField.isVisible, - size: DEFAULT_VIEW_FIELD_SIZE, - position: inputField.position, - aggregateOperation: null, - overrides: null, - universalOverrides: null, - isActive: true, - createdAt: now, + const updatedField: FlatViewField = { + ...existingField, + ...sanitizedFieldProps, + overrides, updatedAt: now, - deletedAt: null, }; + + if (sanitizedFieldProps.viewFieldGroupId !== undefined) { + updatedField.viewFieldGroupUniversalIdentifier = null; + } + + if (isDefined(overrides)) { + updatedField.universalOverrides = + fromViewFieldOverridesToUniversalOverrides({ + overrides, + viewFieldGroupUniversalIdentifierById: {}, + }); + } else { + updatedField.universalOverrides = null; + } + + viewFieldsToUpdate.push(updatedField); + continue; + } + + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: inputField.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, }); + if ( + !isDefined(fieldMetadata) || + !isFieldMetadataEligibleForFieldsWidget({ + fieldName: fieldMetadata.name, + fieldType: fieldMetadata.type, + isLabelIdentifierField: + fieldMetadata.id === labelIdentifierFieldMetadataId, + }) + ) { + continue; + } + + const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } = + resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewField', + foreignKeyValues: { + fieldMetadataId: inputField.fieldMetadataId, + viewId, + }, + flatEntityMaps: { + flatFieldMetadataMaps, + flatViewMaps, + }, + }); + + viewFieldsToCreate.push({ + id: v4(), + workspaceId, + applicationId, + universalIdentifier: v4(), + applicationUniversalIdentifier, + fieldMetadataId: inputField.fieldMetadataId, + fieldMetadataUniversalIdentifier, + viewId, + viewUniversalIdentifier, + viewFieldGroupId: null, + viewFieldGroupUniversalIdentifier: null, + isVisible: inputField.isVisible, + size: DEFAULT_VIEW_FIELD_SIZE, + position: inputField.position, + aggregateOperation: null, + overrides: null, + universalOverrides: null, + isActive: true, + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + } + const { toHardDelete: customGroupsToDelete, toDeactivate: deactivatedGroupUpdates, diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts index a2a7ea3878..6064cef9de 100644 --- a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts @@ -6,6 +6,7 @@ import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metada import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service'; import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type'; +import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util'; import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service'; import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util'; @@ -54,7 +55,7 @@ export class MetadataEventPublisher { metadataEventBatch as MetadataEventBatch<'objectMetadata'>, ); default: - return metadataEventBatch; + return sanitizeOverridableEntityEventBatch(metadataEventBatch); } } diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/sanitize-overridable-entity-event-batch.util.spec.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/sanitize-overridable-entity-event-batch.util.spec.ts new file mode 100644 index 0000000000..ae6daa64c0 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/sanitize-overridable-entity-event-batch.util.spec.ts @@ -0,0 +1,378 @@ +import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type'; +import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util'; + +const makeViewFieldRecord = ( + overrides?: Partial>, +): Record => ({ + id: 'vf-1', + workspaceId: 'ws-1', + applicationId: 'app-1', + universalIdentifier: 'uid-1', + isVisible: true, + size: 200, + position: 0, + aggregateOperation: null, + viewFieldGroupId: null, + fieldMetadataId: 'fm-1', + viewId: 'view-1', + isActive: true, + overrides: null, + createdAt: '2025-01-01', + updatedAt: '2025-01-01', + deletedAt: null, + ...overrides, +}); + +const makeBatch = ( + metadataName: string, + events: Record[], +): MetadataEventBatch => + ({ + name: `metadata.${metadataName}.updated`, + workspaceId: 'ws-1', + metadataName, + type: 'updated', + events, + }) as MetadataEventBatch; + +describe('sanitizeOverridableEntityEventBatch', () => { + describe('non-overridable entity (pass-through)', () => { + it('should return the batch unchanged for entities without overrides config', () => { + const batch = makeBatch('view', [ + { + type: 'updated', + metadataName: 'view', + recordId: 'v-1', + properties: { + updatedFields: ['name'], + diff: { name: { before: 'old', after: 'new' } }, + before: { id: 'v-1', name: 'old' }, + after: { id: 'v-1', name: 'new' }, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result).toBe(batch); + }); + }); + + describe('override resolution', () => { + it('should resolve overrides into base properties and strip overrides/isActive', () => { + const after = makeViewFieldRecord({ + isVisible: true, + overrides: { isVisible: false }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'created', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { after }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const createdRecord = ( + result.events[0] as { properties: { after: Record } } + ).properties.after; + + expect(createdRecord.isVisible).toBe(false); + expect(createdRecord).not.toHaveProperty('overrides'); + expect(createdRecord).not.toHaveProperty('isActive'); + }); + + it('should strip overrides and isActive even when overrides is null', () => { + const after = makeViewFieldRecord({ overrides: null }); + + const batch = makeBatch('viewField', [ + { + type: 'created', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { after }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const createdRecord = ( + result.events[0] as { properties: { after: Record } } + ).properties.after; + + expect(createdRecord.isVisible).toBe(true); + expect(createdRecord).not.toHaveProperty('overrides'); + expect(createdRecord).not.toHaveProperty('isActive'); + }); + + it('should resolve multiple override properties', () => { + const after = makeViewFieldRecord({ + isVisible: true, + size: 200, + position: 0, + overrides: { isVisible: false, size: 300, position: 5 }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'created', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { after }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const createdRecord = ( + result.events[0] as { properties: { after: Record } } + ).properties.after; + + expect(createdRecord.isVisible).toBe(false); + expect(createdRecord.size).toBe(300); + expect(createdRecord.position).toBe(5); + }); + + it('should resolve overrides on both before and after for update events', () => { + const before = makeViewFieldRecord({ + isVisible: true, + overrides: null, + }); + const after = makeViewFieldRecord({ + isVisible: true, + overrides: { isVisible: false }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'updated', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { + updatedFields: ['overrides'], + diff: {}, + before, + after, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const event = result.events[0] as { + properties: { + before: Record; + after: Record; + }; + }; + + expect(event.properties.before.isVisible).toBe(true); + expect(event.properties.after.isVisible).toBe(false); + }); + + it('should resolve overrides on before for delete events', () => { + const before = makeViewFieldRecord({ + isVisible: true, + overrides: { isVisible: false }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'deleted', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { before }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const deletedRecord = ( + result.events[0] as { properties: { before: Record } } + ).properties.before; + + expect(deletedRecord.isVisible).toBe(false); + expect(deletedRecord).not.toHaveProperty('overrides'); + expect(deletedRecord).not.toHaveProperty('isActive'); + }); + }); + + describe('isActive transitions', () => { + it('should drop create events when isActive is false', () => { + const after = makeViewFieldRecord({ isActive: false }); + + const batch = makeBatch('viewField', [ + { + type: 'created', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { after }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result.events).toHaveLength(0); + }); + + it('should convert update to delete when entity is deactivated', () => { + const before = makeViewFieldRecord({ isActive: true }); + const after = makeViewFieldRecord({ isActive: false }); + + const batch = makeBatch('viewField', [ + { + type: 'updated', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { + updatedFields: ['isActive'], + diff: {}, + before, + after, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result.events).toHaveLength(1); + expect(result.events[0].type).toBe('deleted'); + expect( + ( + result.events[0] as { + properties: { before: Record }; + } + ).properties.before, + ).not.toHaveProperty('isActive'); + }); + + it('should convert update to create when entity is reactivated', () => { + const before = makeViewFieldRecord({ isActive: false }); + const after = makeViewFieldRecord({ + isActive: true, + overrides: { isVisible: false }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'updated', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { + updatedFields: ['isActive'], + diff: {}, + before, + after, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result.events).toHaveLength(1); + expect(result.events[0].type).toBe('created'); + + const createdRecord = ( + result.events[0] as { properties: { after: Record } } + ).properties.after; + + expect(createdRecord.isVisible).toBe(false); + expect(createdRecord).not.toHaveProperty('overrides'); + }); + + it('should drop update events when both before and after are inactive', () => { + const before = makeViewFieldRecord({ isActive: false }); + const after = makeViewFieldRecord({ + isActive: false, + overrides: { isVisible: false }, + }); + + const batch = makeBatch('viewField', [ + { + type: 'updated', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { + updatedFields: ['overrides'], + diff: {}, + before, + after, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result.events).toHaveLength(0); + }); + + it('should keep update events when both before and after are active', () => { + const before = makeViewFieldRecord({ isActive: true }); + const after = makeViewFieldRecord({ isActive: true, size: 300 }); + + const batch = makeBatch('viewField', [ + { + type: 'updated', + metadataName: 'viewField', + recordId: 'vf-1', + properties: { + updatedFields: ['size'], + diff: {}, + before, + after, + }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + expect(result.events).toHaveLength(1); + expect(result.events[0].type).toBe('updated'); + }); + }); + + describe('pageLayoutWidget (another overridable entity)', () => { + it('should resolve overrides for pageLayoutWidget', () => { + const after = { + id: 'plw-1', + workspaceId: 'ws-1', + applicationId: 'app-1', + universalIdentifier: 'uid-1', + title: 'Original', + type: 'FIELDS', + position: '{}', + gridPosition: '{}', + configuration: '{}', + conditionalDisplay: null, + pageLayoutTabId: 'plt-1', + isActive: true, + overrides: { title: 'Overridden Title' }, + createdAt: '2025-01-01', + updatedAt: '2025-01-01', + deletedAt: null, + }; + + const batch = makeBatch('pageLayoutWidget', [ + { + type: 'created', + metadataName: 'pageLayoutWidget', + recordId: 'plw-1', + properties: { after }, + }, + ]); + + const result = sanitizeOverridableEntityEventBatch(batch); + + const createdRecord = ( + result.events[0] as { properties: { after: Record } } + ).properties.after; + + expect(createdRecord.title).toBe('Overridden Title'); + expect(createdRecord).not.toHaveProperty('overrides'); + expect(createdRecord).not.toHaveProperty('isActive'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util.ts new file mode 100644 index 0000000000..089bf71b79 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util.ts @@ -0,0 +1,116 @@ +import { type AllMetadataName } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type'; +import { + type CreateMetadataEvent, + type DeleteMetadataEvent, + type MetadataEvent, + type UpdateMetadataEvent, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +const OVERRIDABLE_ENTITY_METADATA_NAMES = new Set([ + 'viewField', + 'viewFieldGroup', + 'pageLayoutTab', + 'pageLayoutWidget', +]); + +const resolveRecordOverrides = ( + record: Record, +): Record => { + const { overrides, isActive, ...base } = record; + + if (!isDefined(overrides)) { + return base; + } + + return { ...base, ...(overrides as Record) }; +}; + +const sanitizeCreatedEvent = ( + event: CreateMetadataEvent, +): MetadataEvent | null => { + const after = event.properties.after as Record; + + if (after.isActive === false) { + return null; + } + + return { + ...event, + properties: { after: resolveRecordOverrides(after) }, + } as typeof event; +}; + +const sanitizeDeletedEvent = ( + event: DeleteMetadataEvent, +): MetadataEvent => { + const before = event.properties.before as Record; + + return { + ...event, + properties: { before: resolveRecordOverrides(before) }, + } as typeof event; +}; + +const sanitizeUpdatedEvent = ( + event: UpdateMetadataEvent, +): MetadataEvent | null => { + const before = event.properties.before as Record; + const after = event.properties.after as Record; + + if (before.isActive === false && after.isActive === false) { + return null; + } + + if (after.isActive === false) { + return { + type: 'deleted', + metadataName: event.metadataName, + recordId: event.recordId, + properties: { before: resolveRecordOverrides(before) }, + } as MetadataEvent; + } + + if (before.isActive === false) { + return { + type: 'created', + metadataName: event.metadataName, + recordId: event.recordId, + properties: { after: resolveRecordOverrides(after) }, + } as MetadataEvent; + } + + return { + ...event, + properties: { + ...event.properties, + before: resolveRecordOverrides(before), + after: resolveRecordOverrides(after), + }, + } as typeof event; +}; + +export const sanitizeOverridableEntityEventBatch = ( + metadataEventBatch: MetadataEventBatch, +): MetadataEventBatch => { + if (!OVERRIDABLE_ENTITY_METADATA_NAMES.has(metadataEventBatch.metadataName)) { + return metadataEventBatch; + } + + const events = metadataEventBatch.events + .map((event) => { + switch (event.type) { + case 'created': + return sanitizeCreatedEvent(event); + case 'updated': + return sanitizeUpdatedEvent(event); + case 'deleted': + return sanitizeDeletedEvent(event); + } + }) + .filter(isDefined); + + return { ...metadataEventBatch, events }; +};