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, }); }