diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 33cd02e079..0c66c3bb01 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -19,6 +19,7 @@ import { withWorkspaceContext, type ORMWorkspaceContext, } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; import { WorkspaceEntityManager } from './workspace-entity-manager'; @@ -475,6 +476,70 @@ describe('WorkspaceEntityManager', () => { updatedColumns: [], }); }); + + it('should emit the update event with the record re-selected after the write', async () => { + const recordBefore = { + id: 'record-id', + fieldName: 'Old Name', + avatarUrl: 'http://localhost:3000/file/core-picture/abc', + }; + const recordAfter = { + id: 'record-id', + fieldName: 'New Name', + avatarUrl: 'http://localhost:3000/file/core-picture/abc', + }; + const persistedPayloadWithUntouchedColumnsNulled = { + id: 'record-id', + fieldName: 'New Name', + avatarUrl: '', + }; + + (formatResult as jest.Mock).mockReturnValue([ + persistedPayloadWithUntouchedColumnsNulled, + ]); + + const findSpy = jest + .spyOn(entityManager, 'find') + .mockResolvedValueOnce([recordBefore]) + .mockResolvedValueOnce([recordAfter]); + + await withWorkspaceContext(mockWorkspaceContext, () => + entityManager.save( + 'test-entity', + { id: 'record-id', fieldName: 'New Name' }, + { reload: false }, + mockPermissionOptions, + ), + ); + + expect(findSpy).toHaveBeenCalledTimes(2); + + const emitDatabaseBatchEvent = mockInternalContext.eventEmitterService + .emitDatabaseBatchEvent as jest.Mock; + const updatedBatchEvent = emitDatabaseBatchEvent.mock.calls[0][0]; + + expect(updatedBatchEvent.events[0].properties.after).toBe(recordAfter); + expect(updatedBatchEvent.events[0].properties.updatedFields).toEqual([ + 'fieldName', + ]); + }); + + it('should not re-select when the save only creates records', async () => { + (formatResult as jest.Mock).mockReturnValue([{ id: 'created-id' }]); + + const findSpy = jest.spyOn(entityManager, 'find').mockResolvedValue([]); + + await withWorkspaceContext(mockWorkspaceContext, () => + entityManager.save( + 'test-entity', + { fieldName: 'New Name' }, + { reload: false }, + mockPermissionOptions, + ), + ); + + expect(findSpy).toHaveBeenCalledTimes(1); + }); }); describe('Update Methods', () => { diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index 26116fbd78..433267f11e 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -3,7 +3,7 @@ import { type ObjectsPermissions, type ObjectsPermissionsByRoleId, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { type DeleteResult, EntityManager, @@ -1210,6 +1210,7 @@ export class WorkspaceEntityManager extends EntityManager { entityTarget, { where: { id: In(entityIds) }, + withDeleted: true, }, { shouldBypassPermissionChecks: true }, // Bypass as this is for event emission ); @@ -1301,13 +1302,25 @@ export class WorkspaceEntityManager extends EntityManager { this.internalContext.flatFieldMetadataMaps, ); - const updatedEntities = formattedResult.filter( - (entity) => beforeUpdateMapById[entity.id], - ); const createdEntities = formattedResult.filter( (entity) => !beforeUpdateMapById[entity.id], ); + const updatedEntityIds = formattedResult + .map((entity) => entity.id) + .filter((entityId) => isDefined(beforeUpdateMapById[entityId])); + + const updatedEntities = isNonEmptyArray(updatedEntityIds) + ? await this.find( + entityTarget, + { + where: { id: In(updatedEntityIds) }, + withDeleted: true, + }, + { shouldBypassPermissionChecks: true }, // Bypass as this is for event emission + ) + : []; + this.internalContext.eventEmitterService.emitDatabaseBatchEvent( formatTwentyOrmEventToDatabaseBatchEvent({ action: DatabaseEventAction.UPDATED, @@ -1315,9 +1328,7 @@ export class WorkspaceEntityManager extends EntityManager { flatFieldMetadataMaps: this.internalContext.flatFieldMetadataMaps, workspaceId: this.internalContext.workspaceId, recordsAfter: updatedEntities, - recordsBefore: updatedEntities.map( - (entity) => beforeUpdateMapById[entity.id], - ), + recordsBefore: beforeUpdate, }), ); diff --git a/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.spec.ts b/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.spec.ts new file mode 100644 index 0000000000..3c909584f5 --- /dev/null +++ b/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.spec.ts @@ -0,0 +1,94 @@ +import { type ObjectRecordUpdateEvent } from 'twenty-shared/database-events'; + +import { type FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; +import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; +import { WorkspaceMemberAvatarFileDeletionListener } from 'src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +const WORKSPACE_ID = 'ec6d123f-0d1c-4b3a-9c1f-2b1a9c8d7e6f'; +const OLD_FILE_ID = '11111111-1111-4111-8111-111111111111'; +const NEW_FILE_ID = '22222222-2222-4222-8222-222222222222'; +const OLD_URL = `http://localhost:3000/file/core-picture/${OLD_FILE_ID}`; +const NEW_URL = `http://localhost:3000/file/core-picture/${NEW_FILE_ID}`; + +const buildUpdateBatch = ({ + before, + after, + updatedFields, +}: { + before: Partial; + after: Partial; + updatedFields: string[]; +}): WorkspaceEventBatch< + ObjectRecordUpdateEvent +> => + ({ + workspaceId: WORKSPACE_ID, + events: [ + { + properties: { before, after, updatedFields, diff: {} }, + }, + ], + }) as unknown as WorkspaceEventBatch< + ObjectRecordUpdateEvent + >; + +describe('WorkspaceMemberAvatarFileDeletionListener', () => { + let listener: WorkspaceMemberAvatarFileDeletionListener; + let deleteCorePicture: jest.Mock; + + beforeEach(() => { + deleteCorePicture = jest.fn().mockResolvedValue(undefined); + + listener = new WorkspaceMemberAvatarFileDeletionListener({ + deleteCorePicture, + } as unknown as FileCorePictureService); + }); + + it('does not delete the avatar file when avatarUrl is unchanged', async () => { + await listener.handleUpdate( + buildUpdateBatch({ + before: { avatarUrl: OLD_URL }, + after: { + name: { firstName: 'Tim', lastName: 'Apple' }, + avatarUrl: OLD_URL, + }, + updatedFields: ['name'], + }), + ); + + expect(deleteCorePicture).not.toHaveBeenCalled(); + }); + + it('deletes the previous avatar file when the avatar is replaced', async () => { + await listener.handleUpdate( + buildUpdateBatch({ + before: { avatarUrl: OLD_URL }, + after: { avatarUrl: NEW_URL }, + updatedFields: ['avatarUrl'], + }), + ); + + expect(deleteCorePicture).toHaveBeenCalledTimes(1); + expect(deleteCorePicture).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + fileId: OLD_FILE_ID, + }); + }); + + it('deletes the previous avatar file when the avatar is removed', async () => { + await listener.handleUpdate( + buildUpdateBatch({ + before: { avatarUrl: OLD_URL }, + after: { avatarUrl: null }, + updatedFields: ['avatarUrl'], + }), + ); + + expect(deleteCorePicture).toHaveBeenCalledTimes(1); + expect(deleteCorePicture).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + fileId: OLD_FILE_ID, + }); + }); +});