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
This commit is contained in:
Lucas Bordeau
2026-01-27 21:09:20 +00:00
committed by GitHub
parent 6b38686d87
commit 2ffe2d8aa6
15 changed files with 441 additions and 28 deletions
@@ -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,
},
});
@@ -168,6 +168,7 @@ export const useDeleteOneRecord = ({
objectMetadataItem,
operation: {
type: 'delete-one',
deletedRecordId: idToDelete,
},
});
@@ -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 = <T>({
objectMetadataItem,
operation: {
type: 'delete-many',
deletedRecordIds: allDeletedRecordIds,
},
});
@@ -203,6 +203,7 @@ export const useRestoreManyRecords = ({
objectMetadataItem,
operation: {
type: 'restore-many',
restoredRecords: restoredRecordsForThisBatch,
},
});
@@ -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,
],
);
@@ -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<string, string[]>();
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,
};
};
@@ -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();
}
};
@@ -0,0 +1 @@
export const SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION = 50;
@@ -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';
};
@@ -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 };
};
@@ -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,
],
);
@@ -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 };
};
@@ -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;
@@ -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<FlatFieldMetadata>;
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(
@@ -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<FlatFieldMetadata>,
): 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<FlatFieldMetadata>,
@@ -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<FlatFieldMetadata>,
@@ -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,
});
}