From 2ffe2d8aa66e5d3abff326fa088088240cc44f0b Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 27 Jan 2026 22:09:20 +0100 Subject: [PATCH] Add delete and restore event handling for table and board (#17489) This PR adds what is required to handle soft-delete and restore SSE events in virtualized table and board. Restore is not handled in board for now as it requires respecting sorts when inserting record ids. Since virtualized table is refetching small chunks, we just refetch for now. The long term goal is to handle event handling without refetching in all main components, and also handle SSE events that have the same origin that the current tab. But for now we implement what is easily doable. # QA Delete between table and board (delete only) : https://github.com/user-attachments/assets/715dd44a-007a-44ab-bf49-5ef039cd57c3 Delete and restore between table and table : https://github.com/user-attachments/assets/f2122519-e969-491f-b71c-018d0f85bd86 --- .../hooks/useDeleteManyRecords.ts | 5 +- .../object-record/hooks/useDeleteOneRecord.ts | 1 + .../hooks/useIncrementalDeleteManyRecords.ts | 5 +- .../hooks/useRestoreManyRecords.ts | 1 + .../RecordBoardDataChangedEffect.tsx | 24 ++++ .../hooks/useRemoveRecordsFromBoard.ts | 101 +++++++++++++++++ ...ecordTableVirtualizedDataChangedEffect.tsx | 14 ++- ...imeInMsToAvoidSseOwnEventsRaceCondition.ts | 1 + .../types/ObjectRecordOperation.ts | 26 +++-- ...ggerOptimisticEffectFromSseDeleteEvents.ts | 104 ++++++++++++++++++ ...useTriggerOptimisticEffectFromSseEvents.ts | 22 ++++ ...gerOptimisticEffectFromSseRestoreEvents.ts | 102 +++++++++++++++++ ...ventToObjectRecordOperationBrowserEvent.ts | 26 ++++- ...rls-row-level-permission-predicate.util.ts | 21 +++- .../workspace-event-emitter.service.ts | 16 ++- 15 files changed, 441 insertions(+), 28 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition.ts create mode 100644 packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseDeleteEvents.ts create mode 100644 packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseRestoreEvents.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 5c52c4188fb..82043378d73 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -1,5 +1,6 @@ import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; import { apiConfigState } from '@/client-config/states/apiConfigState'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -16,11 +17,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; -import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; -import { FeatureFlagKey } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; import { sleep } from '~/utils/sleep'; type useDeleteManyRecordProps = { @@ -239,6 +239,7 @@ export const useDeleteManyRecords = ({ objectMetadataItem, operation: { type: 'delete-many', + deletedRecordIds: recordIdsToDelete, }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index ad8b02b4812..0779d75dcd9 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -168,6 +168,7 @@ export const useDeleteOneRecord = ({ objectMetadataItem, operation: { type: 'delete-one', + deletedRecordId: idToDelete, }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts index 289fb9cf763..6729d74ff15 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts @@ -1,4 +1,5 @@ import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -16,11 +17,10 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; -import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useCallback } from 'react'; -import { FeatureFlagKey } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; import { sleep } from '~/utils/sleep'; const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50; @@ -252,6 +252,7 @@ export const useIncrementalDeleteManyRecords = ({ objectMetadataItem, operation: { type: 'delete-many', + deletedRecordIds: allDeletedRecordIds, }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts index 39aa17ad03a..79a04228750 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts @@ -203,6 +203,7 @@ export const useRestoreManyRecords = ({ objectMetadataItem, operation: { type: 'restore-many', + restoredRecords: restoredRecordsForThisBatch, }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx index 62ddd85583b..baf5bbb67c1 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx @@ -1,5 +1,6 @@ import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent'; import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs'; +import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard'; import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery'; import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; @@ -32,6 +33,8 @@ export const RecordBoardDataChangedEffect = () => { recordIndexRecordIdsByGroupComponentFamilyState, ); + const { removeRecordsFromBoard } = useRemoveRecordsFromBoard(); + const handleObjectRecordOperation = useRecoilCallback( ({ snapshot }) => ( @@ -126,6 +129,26 @@ export const RecordBoardDataChangedEffect = () => { } break; } + case 'delete-one': { + const removedRecordId = objectRecordOperation.deletedRecordId; + + removeRecordsFromBoard({ + recordIdsToRemove: [removedRecordId], + }); + return; + } + case 'delete-many': { + const removedRecordIds = objectRecordOperation.deletedRecordIds; + + removeRecordsFromBoard({ + recordIdsToRemove: removedRecordIds, + }); + return; + } + case 'restore-many': + case 'restore-one': { + return; + } default: { triggerRecordBoardInitialQuery(); } @@ -137,6 +160,7 @@ export const RecordBoardDataChangedEffect = () => { recordIndexGroupFieldMetadataItemCallbackState, recordGroupFromGroupValueCallbackState, recordIndexRecordIdsByGroupCallbackState, + removeRecordsFromBoard, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts new file mode 100644 index 00000000000..249c5719475 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts @@ -0,0 +1,101 @@ +import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useRemoveRecordsFromBoard = () => { + const recordIndexGroupFieldMetadataItemCallbackState = + useRecoilComponentCallbackState( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const recordGroupFromGroupValueFamilyCallbackState = + useRecoilComponentCallbackState( + recordGroupFromGroupValueComponentFamilySelector, + ); + + const recordIndexRecordIdsByGroupFamilyCallbackState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const removeRecordsFromBoard = useRecoilCallback( + ({ snapshot, set }) => + ({ recordIdsToRemove }: { recordIdsToRemove: string[] }) => { + const recordIdsToRemoveByGroup = new Map(); + + for (const recordIdToRemove of recordIdsToRemove) { + const recordToRemove = getSnapshotValue( + snapshot, + recordStoreFamilyState(recordIdToRemove), + ); + + if (!isDefined(recordToRemove)) { + continue; + } + + const recordIndexGroupFieldMetadataItem = getSnapshotValue( + snapshot, + recordIndexGroupFieldMetadataItemCallbackState, + ); + + if (!isDefined(recordIndexGroupFieldMetadataItem)) { + continue; + } + + const recordGroupValue = + recordToRemove[recordIndexGroupFieldMetadataItem.name]; + + const recordGroupDefinitionFromGroupValue = getSnapshotValue( + snapshot, + recordGroupFromGroupValueFamilyCallbackState({ recordGroupValue }), + ); + + if (!isDefined(recordGroupDefinitionFromGroupValue)) { + continue; + } + + const groupId = recordGroupDefinitionFromGroupValue.id; + + if (!recordIdsToRemoveByGroup.has(groupId)) { + recordIdsToRemoveByGroup.set(groupId, []); + } + + recordIdsToRemoveByGroup.get(groupId)?.push(recordIdToRemove); + } + + for (const [ + groupId, + recordIdsToRemoveInGroup, + ] of recordIdsToRemoveByGroup) { + const currentRecordIdsForGroup = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupFamilyCallbackState(groupId), + ); + + const recordIdsWithoutRemovedRecords = + currentRecordIdsForGroup.filter( + (recordId) => !recordIdsToRemoveInGroup.includes(recordId), + ); + + set( + recordIndexRecordIdsByGroupFamilyCallbackState(groupId), + recordIdsWithoutRemovedRecords, + ); + } + }, + [ + recordIndexGroupFieldMetadataItemCallbackState, + recordGroupFromGroupValueFamilyCallbackState, + recordIndexRecordIdsByGroupFamilyCallbackState, + ], + ); + + return { + removeRecordsFromBoard, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx index 81f3251b32a..65926946466 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx @@ -1,9 +1,11 @@ import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION } from '@/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition'; import { useGetShouldResetTableVirtualizationForUpdateInputs } from '@/object-record/record-table/virtualization/hooks/useGetShouldResetTableVirtualizationForUpdateInputs'; import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged'; import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail'; +import { useDebouncedCallback } from 'use-debounce'; export const RecordTableVirtualizedDataChangedEffect = () => { const { objectMetadataItem } = useRecordIndexContextOrThrow(); @@ -15,6 +17,14 @@ export const RecordTableVirtualizedDataChangedEffect = () => { const { getShouldResetTableVirtualizationForUpdateInputs } = useGetShouldResetTableVirtualizationForUpdateInputs(); + const debouncedResertVirtualizationBecauseDataChanged = useDebouncedCallback( + resetVirtualizationBecauseDataChanged, + SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION, + { + leading: false, + }, + ); + const handleObjectRecordOperation = ( objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail, ) => { @@ -34,10 +44,10 @@ export const RecordTableVirtualizedDataChangedEffect = () => { getShouldResetTableVirtualizationForUpdateInputs(updateInputs); if (shouldResetForUpdateOperation) { - resetVirtualizationBecauseDataChanged(); + debouncedResertVirtualizationBecauseDataChanged(); } } else { - resetVirtualizationBecauseDataChanged(); + debouncedResertVirtualizationBecauseDataChanged(); } }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition.ts new file mode 100644 index 00000000000..15490381642 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition.ts @@ -0,0 +1 @@ +export const SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION = 50; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordOperation.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordOperation.ts index 1677073374c..183470b88a6 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordOperation.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordOperation.ts @@ -19,13 +19,21 @@ export type ObjectRecordOperation = createdRecord: ObjectRecord; } | { - type: - | 'create-many' - | 'destroy-one' - | 'destroy-many' - | 'delete-one' - | 'delete-many' - | 'restore-one' - | 'restore-many' - | 'merge-records'; + type: 'delete-one'; + deletedRecordId: string; + } + | { + type: 'delete-many'; + deletedRecordIds: string[]; + } + | { + type: 'restore-one'; + restoredRecord: ObjectRecord; + } + | { + type: 'restore-many'; + restoredRecords: ObjectRecord[]; + } + | { + type: 'create-many' | 'merge-records' | 'destroy-one' | 'destroy-many'; }; diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseDeleteEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseDeleteEvents.ts new file mode 100644 index 00000000000..f8284a39970 --- /dev/null +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseDeleteEvents.ts @@ -0,0 +1,104 @@ +import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { useCallback } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { + DatabaseEventAction, + type ObjectRecordEvent, +} from '~/generated/graphql'; + +export const useTriggerOptimisticEffectFromSseDeleteEvents = () => { + const apolloCoreClient = useApolloCoreClient(); + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { refetchAggregateQueriesForObjectMetadataItem } = + useRefetchAggregateQueriesForObjectMetadataItem(); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const debouncedRefetchAggregateQueriesForObjectMetadataItem = + useDebouncedCallback(refetchAggregateQueriesForObjectMetadataItem, 100); + + const triggerOptimisticEffectFromSseDeleteEvents = useCallback( + ({ + objectRecordEvents, + objectMetadataItem, + }: { + objectRecordEvents: ObjectRecordEvent[]; + objectMetadataItem: ObjectMetadataItem; + }) => { + const deleteEvents = objectRecordEvents.filter((objectRecordEvent) => { + return objectRecordEvent.action === DatabaseEventAction.DELETED; + }); + + const cache = apolloCoreClient.cache; + + const recordsBeforeDelete = deleteEvents.map((deleteEvent) => { + const recordBeforeDelete = deleteEvent.properties.before; + + return { + ...recordBeforeDelete, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + } as RecordGqlNode; + }); + + const recordsAfterDelete = deleteEvents.map((deleteEvent) => { + const recordAfterDelete = deleteEvent.properties.after; + + return { + ...recordAfterDelete, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + } as RecordGqlNode; + }); + + if (recordsAfterDelete.length === 0) { + return; + } + + for (const recordAfterDelete of recordsAfterDelete) { + const recordGqlFields = { + deletedAt: true, + }; + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: recordAfterDelete, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + } + + triggerUpdateRecordOptimisticEffectByBatch({ + cache, + objectMetadataItem, + objectMetadataItems, + currentRecords: recordsBeforeDelete, + updatedRecords: recordsAfterDelete, + upsertRecordsInStore, + objectPermissionsByObjectMetadataId, + }); + + debouncedRefetchAggregateQueriesForObjectMetadataItem({ + objectMetadataItem, + }); + }, + [ + apolloCoreClient, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + debouncedRefetchAggregateQueriesForObjectMetadataItem, + upsertRecordsInStore, + ], + ); + + return { triggerOptimisticEffectFromSseDeleteEvents }; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts index e3ed19641b7..491ddef3993 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts @@ -1,5 +1,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useTriggerOptimisticEffectFromSseCreateEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseCreateEvents'; +import { useTriggerOptimisticEffectFromSseDeleteEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseDeleteEvents'; +import { useTriggerOptimisticEffectFromSseRestoreEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseRestoreEvents'; import { useTriggerOptimisticEffectFromSseUpdateEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents'; import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType'; import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular'; @@ -19,6 +21,12 @@ export const useTriggerOptimisticEffectFromSseEvents = () => { const { triggerOptimisticEffectFromSseCreateEvents } = useTriggerOptimisticEffectFromSseCreateEvents(); + const { triggerOptimisticEffectFromSseDeleteEvents } = + useTriggerOptimisticEffectFromSseDeleteEvents(); + + const { triggerOptimisticEffectFromSseRestoreEvents } = + useTriggerOptimisticEffectFromSseRestoreEvents(); + const triggerOptimisticEffectFromSseEvents = useCallback( ({ objectRecordEvents }: { objectRecordEvents: ObjectRecordEvent[] }) => { const objectRecordEventsByObjectMetadataItemNameSingular = @@ -68,6 +76,18 @@ export const useTriggerOptimisticEffectFromSseEvents = () => { objectMetadataItem, }); break; + case DatabaseEventAction.DELETED: + triggerOptimisticEffectFromSseDeleteEvents({ + objectRecordEvents: objectRecordEventsForThisEventType, + objectMetadataItem, + }); + break; + case DatabaseEventAction.RESTORED: + triggerOptimisticEffectFromSseRestoreEvents({ + objectRecordEvents: objectRecordEventsForThisEventType, + objectMetadataItem, + }); + break; } } } @@ -76,6 +96,8 @@ export const useTriggerOptimisticEffectFromSseEvents = () => { objectMetadataItems, triggerOptimisticEffectFromSseUpdateEvents, triggerOptimisticEffectFromSseCreateEvents, + triggerOptimisticEffectFromSseDeleteEvents, + triggerOptimisticEffectFromSseRestoreEvents, ], ); diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseRestoreEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseRestoreEvents.ts new file mode 100644 index 00000000000..0c8c1243287 --- /dev/null +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseRestoreEvents.ts @@ -0,0 +1,102 @@ +import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { useCallback } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { + DatabaseEventAction, + type ObjectRecordEvent, +} from '~/generated/graphql'; + +export const useTriggerOptimisticEffectFromSseRestoreEvents = () => { + const apolloCoreClient = useApolloCoreClient(); + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { refetchAggregateQueriesForObjectMetadataItem } = + useRefetchAggregateQueriesForObjectMetadataItem(); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const debouncedRefetchAggregateQueriesForObjectMetadataItem = + useDebouncedCallback(refetchAggregateQueriesForObjectMetadataItem, 100); + + const triggerOptimisticEffectFromSseRestoreEvents = useCallback( + ({ + objectRecordEvents, + objectMetadataItem, + }: { + objectRecordEvents: ObjectRecordEvent[]; + objectMetadataItem: ObjectMetadataItem; + }) => { + const restoreEvents = objectRecordEvents.filter((objectRecordEvent) => { + return objectRecordEvent.action === DatabaseEventAction.RESTORED; + }); + + const cache = apolloCoreClient.cache; + + const recordsBeforeRestore = restoreEvents.map((restoreEvent) => { + const recordBeforeRestore = restoreEvent.properties.before; + return { + ...recordBeforeRestore, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + } as RecordGqlNode; + }); + + const recordsAfterRestore = restoreEvents.map((restoreEvent) => { + const recordAfterRestore = restoreEvent.properties.after; + return { + ...recordAfterRestore, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + } as RecordGqlNode; + }); + + if (recordsAfterRestore.length === 0) { + return; + } + + for (const recordAfterRestore of recordsAfterRestore) { + const recordGqlFields = { + deletedAt: true, + }; + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: recordAfterRestore, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + } + + triggerUpdateRecordOptimisticEffectByBatch({ + cache, + objectMetadataItem, + objectMetadataItems, + currentRecords: recordsBeforeRestore, + updatedRecords: recordsAfterRestore, + upsertRecordsInStore, + objectPermissionsByObjectMetadataId, + }); + + debouncedRefetchAggregateQueriesForObjectMetadataItem({ + objectMetadataItem, + }); + }, + [ + apolloCoreClient, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + debouncedRefetchAggregateQueriesForObjectMetadataItem, + upsertRecordsInStore, + ], + ); + + return { triggerOptimisticEffectFromSseRestoreEvents }; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts index 3d62be835f8..87a7d1a8130 100644 --- a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts +++ b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts @@ -77,12 +77,21 @@ export const turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents = ({ if (hasSingleEvent) { objectRecordOperationBrowserEvents.push({ objectMetadataItem, - operation: { type: 'restore-one' }, + operation: { + type: 'restore-one', + restoredRecord: + objectRecordEventsForThisEventType[0].properties.after, + }, }); } else { objectRecordOperationBrowserEvents.push({ objectMetadataItem, - operation: { type: 'restore-many' }, + operation: { + type: 'restore-many', + restoredRecords: objectRecordEventsForThisEventType.map( + (event) => event.properties.after, + ), + }, }); } break; @@ -124,12 +133,21 @@ export const turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents = ({ if (hasSingleEvent) { objectRecordOperationBrowserEvents.push({ objectMetadataItem, - operation: { type: 'delete-one' }, + operation: { + type: 'delete-one', + deletedRecordId: + objectRecordEventsForThisEventType[0].properties.before.id, + }, }); } else { objectRecordOperationBrowserEvents.push({ objectMetadataItem, - operation: { type: 'delete-many' }, + operation: { + type: 'delete-many', + deletedRecordIds: objectRecordEventsForThisEventType.map( + (event) => event.properties.before.id, + ), + }, }); } break; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts index 23647c00c4a..ea5492023c8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts @@ -78,12 +78,14 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any record: any; filter: RecordGqlOperationFilter; flatObjectMetadata: FlatObjectMetadata; flatFieldMetadataMaps: FlatEntityMaps; + shouldIgnoreSoftDeleteDefaultFilter?: boolean; }): boolean => { if (Object.keys(filter).length === 0 && record.deletedAt === null) { return true; @@ -96,6 +98,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter: { [filterKey]: value }, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }), ); } @@ -117,6 +120,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter: andFilter, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }), ) ); @@ -134,6 +138,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter: orFilter, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }), ) ); @@ -146,6 +151,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter: filterValue, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }); } @@ -166,14 +172,21 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ filter: filterValue, flatObjectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }) ); } - if (isLeafFilter(filter)) { - if (isDefined(record.deletedAt) && filter.deletedAt === undefined) { - return false; - } + const shouldTakeDeletedAtIntoAccount = + shouldIgnoreSoftDeleteDefaultFilter !== true; + + const shouldRejectMatchingBecauseRecordIsSoftDeleted = + isLeafFilter(filter) && + shouldTakeDeletedAtIntoAccount && + isDefined(record.deletedAt); + + if (shouldRejectMatchingBecauseRecordIsSoftDeleted) { + return false; } const objectFields = getFlatFieldsFromFlatObjectMetadata( 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 dd9fac3900a..751a366912f 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 @@ -8,6 +8,7 @@ import { } from 'twenty-shared/types'; import { combineFilters, isDefined } from 'twenty-shared/utils'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; 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'; @@ -252,10 +253,10 @@ export class WorkspaceEventEmitterService { } private filterRestrictedFieldsFromEvent( - event: ObjectRecordEvent & { objectNameSingular: string }, + event: ObjectRecordSubscriptionEvent, restrictedFields: RestrictedFieldsPermissions | undefined, flatFieldMetadataMaps: FlatEntityMaps, - ): ObjectRecordEvent & { objectNameSingular: string } { + ): ObjectRecordSubscriptionEvent { if (!restrictedFields || Object.keys(restrictedFields).length === 0) { return event; } @@ -311,7 +312,7 @@ export class WorkspaceEventEmitterService { return { ...event, properties: filteredProperties, - } as ObjectRecordEvent & { objectNameSingular: string }; + } as ObjectRecordSubscriptionEvent; } private getMatchingQueryIds( @@ -322,7 +323,7 @@ export class WorkspaceEventEmitterService { variables?: { filter?: RecordGqlOperationFilter }; } >, - event: ObjectRecordEvent & { objectNameSingular: string }, + event: ObjectRecordSubscriptionEvent, subscriberRLSFilter: RecordGqlOperationFilter | null, objectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, @@ -351,7 +352,7 @@ export class WorkspaceEventEmitterService { objectNameSingular: string; variables?: { filter?: RecordGqlOperationFilter }; }, - event: ObjectRecordEvent & { objectNameSingular: string }, + event: ObjectRecordSubscriptionEvent, subscriberRLSFilter: RecordGqlOperationFilter | null, objectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, @@ -384,11 +385,16 @@ export class WorkspaceEventEmitterService { return true; } + const shouldIgnoreSoftDeleteDefaultFilter = + event.action === DatabaseEventAction.DELETED || + event.action === DatabaseEventAction.RESTORED; + return isRecordMatchingRLSRowLevelPermissionPredicate({ record, filter: combinedFilter, flatObjectMetadata: objectMetadata, flatFieldMetadataMaps, + shouldIgnoreSoftDeleteDefaultFilter, }); }