diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index bc3602150a..3e072c3092 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1448,13 +1448,8 @@ export enum EventLogTable { export type EventSubscription = { __typename?: 'EventSubscription'; eventStreamId: Scalars['String']; - eventWithQueryIdsList: Array; -}; - -export type EventWithQueryIds = { - __typename?: 'EventWithQueryIds'; - event: ObjectRecordEvent; - queryIds: Array; + metadataEventsWithQueryIds: Array; + objectRecordEventsWithQueryIds: Array; }; export type ExecuteOneLogicFunctionInput = { @@ -2138,6 +2133,27 @@ export type MarketplaceAppRoleObjectPermission = { objectUniversalIdentifier: Scalars['String']; }; +export type MetadataEvent = { + __typename?: 'MetadataEvent'; + metadataName: Scalars['String']; + properties: ObjectRecordEventProperties; + recordId: Scalars['String']; + type: MetadataEventAction; +}; + +/** Metadata Event Action */ +export enum MetadataEventAction { + CREATED = 'CREATED', + DELETED = 'DELETED', + UPDATED = 'UPDATED' +} + +export type MetadataEventWithQueryIds = { + __typename?: 'MetadataEventWithQueryIds'; + metadataEvent: MetadataEvent; + queryIds: Array; +}; + export enum ModelProvider { ANTHROPIC = 'ANTHROPIC', GROQ = 'GROQ', @@ -3379,6 +3395,12 @@ export type ObjectRecordEventProperties = { updatedFields?: Maybe>; }; +export type ObjectRecordEventWithQueryIds = { + __typename?: 'ObjectRecordEventWithQueryIds'; + objectRecordEvent: ObjectRecordEvent; + queryIds: Array; +}; + /** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */ export enum ObjectRecordGroupByDateGranularity { DAY = 'DAY', @@ -5781,7 +5803,7 @@ export type FindOneFrontComponentQueryVariables = Exact<{ }>; -export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null }; +export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null }; export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }; @@ -10569,6 +10591,7 @@ export const FindOneFrontComponentDocument = gql` id name applicationId + builtComponentChecksum applicationTokenPair { applicationAccessToken { token diff --git a/packages/twenty-front/src/modules/browser-event/constants/MetadataOperationBrowserEventName.ts b/packages/twenty-front/src/modules/browser-event/constants/MetadataOperationBrowserEventName.ts new file mode 100644 index 0000000000..4153799e14 --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/constants/MetadataOperationBrowserEventName.ts @@ -0,0 +1,2 @@ +export const METADATA_OPERATION_BROWSER_EVENT_NAME = + 'metadata-operation-browser-event'; diff --git a/packages/twenty-front/src/modules/object-record/constants/ObjectRecordOperationBrowserEventName.ts b/packages/twenty-front/src/modules/browser-event/constants/ObjectRecordOperationBrowserEventName.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/constants/ObjectRecordOperationBrowserEventName.ts rename to packages/twenty-front/src/modules/browser-event/constants/ObjectRecordOperationBrowserEventName.ts diff --git a/packages/twenty-front/src/modules/browser-event/hooks/useListenToMetadataOperationBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/hooks/useListenToMetadataOperationBrowserEvent.ts new file mode 100644 index 0000000000..9f109b1954 --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/hooks/useListenToMetadataOperationBrowserEvent.ts @@ -0,0 +1,53 @@ +import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName'; +import { type MetadataOperation } from '@/browser-event/types/MetadataOperation'; +import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail'; +import { useEffect } from 'react'; +import { type AllMetadataName } from 'twenty-shared/metadata'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +export const useListenToMetadataOperationBrowserEvent = < + T extends Record, +>({ + onMetadataOperationBrowserEvent, + metadataName, + operationTypes, +}: { + onMetadataOperationBrowserEvent: ( + detail: MetadataOperationBrowserEventDetail, + ) => void; + metadataName?: AllMetadataName; + operationTypes?: MetadataOperation['type'][]; +}) => { + useEffect(() => { + const handleMetadataOperationEvent = ( + event: CustomEvent>, + ) => { + const detail = event.detail; + + if (isDefined(metadataName) && detail.metadataName !== metadataName) { + return; + } + + if ( + isNonEmptyArray(operationTypes) && + !operationTypes.includes(detail.operation.type) + ) { + return; + } + + onMetadataOperationBrowserEvent(detail); + }; + + window.addEventListener( + METADATA_OPERATION_BROWSER_EVENT_NAME, + handleMetadataOperationEvent as EventListener, + ); + + return () => { + window.removeEventListener( + METADATA_OPERATION_BROWSER_EVENT_NAME, + handleMetadataOperationEvent as EventListener, + ); + }; + }, [metadataName, onMetadataOperationBrowserEvent, operationTypes]); +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useListenToObjectRecordOperationBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent.ts similarity index 89% rename from packages/twenty-front/src/modules/object-record/hooks/useListenToObjectRecordOperationBrowserEvent.ts rename to packages/twenty-front/src/modules/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent.ts index 11b65cc524..703da4ec21 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useListenToObjectRecordOperationBrowserEvent.ts +++ b/packages/twenty-front/src/modules/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent.ts @@ -1,6 +1,6 @@ -import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName'; +import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName'; import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation'; -import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; import { useEffect } from 'react'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/browser-event/types/MetadataOperation.ts b/packages/twenty-front/src/modules/browser-event/types/MetadataOperation.ts new file mode 100644 index 0000000000..e63e9743b2 --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/types/MetadataOperation.ts @@ -0,0 +1,14 @@ +export type MetadataOperation> = + | { + type: 'create'; + createdRecord: T; + } + | { + type: 'update'; + updatedRecord: T; + updatedFields?: string[]; + } + | { + type: 'delete'; + deletedRecordId: string; + }; diff --git a/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts b/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts new file mode 100644 index 0000000000..cff91e9749 --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts @@ -0,0 +1,9 @@ +import { type MetadataOperation } from '@/browser-event/types/MetadataOperation'; +import { type AllMetadataName } from 'twenty-shared/metadata'; + +export type MetadataOperationBrowserEventDetail< + T extends Record, +> = { + metadataName: AllMetadataName; + operation: MetadataOperation; +}; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordOperationBrowserEventDetail.ts b/packages/twenty-front/src/modules/browser-event/types/ObjectRecordOperationBrowserEventDetail.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/types/ObjectRecordOperationBrowserEventDetail.ts rename to packages/twenty-front/src/modules/browser-event/types/ObjectRecordOperationBrowserEventDetail.ts diff --git a/packages/twenty-front/src/modules/browser-event/utils/dispatchMetadataOperationBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/utils/dispatchMetadataOperationBrowserEvent.ts new file mode 100644 index 0000000000..fa1d62cb0c --- /dev/null +++ b/packages/twenty-front/src/modules/browser-event/utils/dispatchMetadataOperationBrowserEvent.ts @@ -0,0 +1,14 @@ +import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName'; +import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail'; + +export const dispatchMetadataOperationBrowserEvent = < + T extends Record, +>( + detail: MetadataOperationBrowserEventDetail, +) => { + window.dispatchEvent( + new CustomEvent(METADATA_OPERATION_BROWSER_EVENT_NAME, { + detail, + }), + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/dispatchObjectRecordOperationBrowserEvent.ts b/packages/twenty-front/src/modules/browser-event/utils/dispatchObjectRecordOperationBrowserEvent.ts similarity index 61% rename from packages/twenty-front/src/modules/object-record/utils/dispatchObjectRecordOperationBrowserEvent.ts rename to packages/twenty-front/src/modules/browser-event/utils/dispatchObjectRecordOperationBrowserEvent.ts index 9956d935f3..91ae6e60bf 100644 --- a/packages/twenty-front/src/modules/object-record/utils/dispatchObjectRecordOperationBrowserEvent.ts +++ b/packages/twenty-front/src/modules/browser-event/utils/dispatchObjectRecordOperationBrowserEvent.ts @@ -1,5 +1,5 @@ -import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName'; -import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail'; +import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; export const dispatchObjectRecordOperationBrowserEvent = ( detail: ObjectRecordOperationBrowserEventDetail, diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index a456e68cdc..f9e852bf3b 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -1,5 +1,6 @@ -import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext'; +import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated'; +import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; @@ -21,8 +22,6 @@ export const FrontComponentRenderer = ({ const { executionContext, frontComponentHostCommunicationApi } = useFrontComponentExecutionContext(); - const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`; - const handleError = useCallback( (error?: Error) => { if (!isDefined(error)) { @@ -43,6 +42,15 @@ export const FrontComponentRenderer = ({ onError: handleError, }); + useOnFrontComponentUpdated({ + frontComponentId, + }); + + const componentUrl = getFrontComponentUrl({ + frontComponentId, + checksum: data?.frontComponent?.builtComponentChecksum, + }); + if ( loading || !isDefined(data?.frontComponent) || diff --git a/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts b/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts index 472e3dffe6..af80db411d 100644 --- a/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts +++ b/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts @@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql` id name applicationId + builtComponentChecksum applicationTokenPair { applicationAccessToken { token diff --git a/packages/twenty-front/src/modules/front-components/hooks/useOnFrontComponentUpdated.ts b/packages/twenty-front/src/modules/front-components/hooks/useOnFrontComponentUpdated.ts new file mode 100644 index 0000000000..79064058c4 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/hooks/useOnFrontComponentUpdated.ts @@ -0,0 +1,37 @@ +import { useUpdateFrontComponentApolloCache } from '@/front-components/hooks/useUpdateFrontComponentApolloCache'; +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { + AllMetadataName, + type FrontComponent, +} from '~/generated-metadata/graphql'; + +type UseOnFrontComponentUpdatedArgs = { + frontComponentId: string; +}; + +export const useOnFrontComponentUpdated = ({ + frontComponentId, +}: UseOnFrontComponentUpdatedArgs) => { + const queryId = `front-component-updated-${frontComponentId}`; + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.frontComponent, + variables: { + filter: { id: { eq: frontComponentId } }, + }, + }, + }); + + const { updateFrontComponentApolloCache } = + useUpdateFrontComponentApolloCache({ + frontComponentId, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.frontComponent, + onMetadataOperationBrowserEvent: updateFrontComponentApolloCache, + }); +}; diff --git a/packages/twenty-front/src/modules/front-components/hooks/useUpdateFrontComponentApolloCache.ts b/packages/twenty-front/src/modules/front-components/hooks/useUpdateFrontComponentApolloCache.ts new file mode 100644 index 0000000000..be50e95f1d --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/hooks/useUpdateFrontComponentApolloCache.ts @@ -0,0 +1,54 @@ +import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail'; +import { useApolloClient } from '@apollo/client'; +import { isDefined } from 'twenty-shared/utils'; +import { + FindOneFrontComponentDocument, + type FindOneFrontComponentQuery, + type FrontComponent, +} from '~/generated-metadata/graphql'; + +type UseUpdateFrontComponentApolloCacheArgs = { + frontComponentId: string; +}; + +export const useUpdateFrontComponentApolloCache = ({ + frontComponentId, +}: UseUpdateFrontComponentApolloCacheArgs) => { + const apolloClient = useApolloClient(); + + const updateFrontComponentApolloCache = ( + detail: MetadataOperationBrowserEventDetail, + ) => { + if (detail.operation.type !== 'update') { + return; + } + + const { updatedRecord } = detail.operation; + + if (!isDefined(updatedRecord) || updatedRecord.id !== frontComponentId) { + return; + } + + apolloClient.cache.updateQuery( + { + query: FindOneFrontComponentDocument, + variables: { id: frontComponentId }, + }, + (existingData) => { + if (!isDefined(existingData?.frontComponent)) { + return existingData; + } + + return { + ...existingData, + frontComponent: { + ...existingData.frontComponent, + ...updatedRecord, + }, + }; + }, + ); + }; + + return { updateFrontComponentApolloCache }; +}; diff --git a/packages/twenty-front/src/modules/front-components/utils/getFrontComponentUrl.ts b/packages/twenty-front/src/modules/front-components/utils/getFrontComponentUrl.ts new file mode 100644 index 0000000000..a5afcfe5a2 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/utils/getFrontComponentUrl.ts @@ -0,0 +1,14 @@ +import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; +import { isDefined } from 'twenty-shared/utils'; + +export const getFrontComponentUrl = ({ + frontComponentId, + checksum, +}: { + frontComponentId: string; + checksum?: string; +}): string => { + return isDefined(checksum) + ? `${REST_API_BASE_URL}/front-components/${frontComponentId}?checksum=${checksum}` + : `${REST_API_BASE_URL}/front-components/${frontComponentId}`; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useIncrementalUpdateManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useIncrementalUpdateManyRecords.test.tsx index 89725cbebe..6d37780d7c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useIncrementalUpdateManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useIncrementalUpdateManyRecords.test.tsx @@ -2,11 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords'; import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords'; import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { renderHook } from '@testing-library/react'; jest.mock('@/object-metadata/hooks/useObjectMetadataItem'); -jest.mock('@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'); +jest.mock('@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'); jest.mock('@/object-record/hooks/useUpdateManyRecords', () => ({ useUpdateManyRecords: jest.fn(), })); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts index 0a5891f82b..52b485602b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts @@ -7,7 +7,7 @@ import { } from '@/object-record/hooks/useCreateManyRecords'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index 94a6075d3a..910d041335 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -19,7 +19,7 @@ import { type FieldActorForInputValue } from '@/object-record/record-field/ui/ty import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { useRecoilValue } from 'recoil'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index fcec8fba88..27e90d5c84 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -26,7 +26,7 @@ import { import { type BaseObjectRecord } from '@/object-record/types/BaseObjectRecord'; import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { useRecoilValue } from 'recoil'; 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 4ce3aee4c6..0a9fd77ae2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -15,7 +15,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; 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 0779d75dcd..70383d7c0a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -14,7 +14,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField'; import { isNull } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts index 54615ea54d..d434015628 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts @@ -11,7 +11,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField'; import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts index df8361938f..ca785c2753 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts @@ -9,7 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField'; import { capitalize, isDefined } from 'twenty-shared/utils'; 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 e4127d55d0..e8a823bb0b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts @@ -16,7 +16,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts index b4550e9ef2..6654c21605 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts @@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { sleep } from '~/utils/sleep'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalUpdateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalUpdateManyRecords.ts index fc113428fe..32b92404b9 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalUpdateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalUpdateManyRecords.ts @@ -5,7 +5,7 @@ import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIn import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts index ab5a59aef1..d779eab9ec 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts @@ -8,7 +8,7 @@ import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQue import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField'; import { getOperationName } from '@apollo/client/utilities'; import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types'; 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 79a0422875..37341f7a96 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts @@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField'; import { useRecoilValue } from 'recoil'; import { capitalize, isDefined } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts index 7189f4f52d..2d9a0c2943 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts @@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation'; 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 { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index 2fa49b2741..184226dac5 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -16,7 +16,7 @@ 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 { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; 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 baf5bbb67c..bc7f220335 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,4 +1,4 @@ -import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent'; +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/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'; @@ -7,7 +7,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte 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 { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { useRecoilCallback } from 'recoil'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx index f9022387db..f47de6b6f2 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables'; -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; export const RecordBoardSSESubscribeEffect = () => { const { recordBoardId } = useContext(RecordBoardContext); @@ -13,7 +13,7 @@ export const RecordBoardSSESubscribeEffect = () => { const queryId = `record-board-${recordBoardId}`; - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular: objectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx index d2e4c78ea1..61af1e42d0 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx @@ -3,7 +3,7 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { type RecordGqlOperationOrderBy } from 'twenty-shared/types'; @@ -32,7 +32,7 @@ export const RecordCalendarSSESubscribeEffect = () => { const queryId = `record-calendar-${recordCalendarId}`; - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular: objectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx index 0542c00e51..d374f234fc 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx @@ -1,4 +1,4 @@ -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; type RecordShowPageSSESubscribeEffectProps = { objectNameSingular: string; @@ -11,7 +11,7 @@ export const RecordShowPageSSESubscribeEffect = ({ }: RecordShowPageSSESubscribeEffectProps) => { const queryId = `record-show-${objectNameSingular}-${recordId}`; - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular, 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 6592694646..f266e45c5c 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,10 +1,10 @@ -import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent'; +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/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 { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; import { useDebouncedCallback } from 'use-debounce'; export const RecordTableVirtualizedDataChangedEffect = () => { diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx index 771f6f14df..db17c0bb12 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx @@ -4,7 +4,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/ import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { computeRecordGqlOperationFilter } from 'twenty-shared/utils'; @@ -26,7 +26,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => { const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`; - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular: objectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx index d856ff3183..58447f4bd2 100644 --- a/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx +++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx @@ -37,11 +37,11 @@ export const SSEEventStreamEffect = () => { const { triggerEventStreamDestroy } = useTriggerEventStreamDestroy(); useEffect(() => { - const isSseClientAvailabble = + const isSseClientAvailable = !isCreatingSseEventStream && !isDestroyingEventStream; const willCreateEventStream = - isSseClientAvailabble && + isSseClientAvailable && isLoggedIn && isSseDbEventsEnabled && isDefined(currentUser) && @@ -51,7 +51,7 @@ export const SSEEventStreamEffect = () => { isNonEmptyArray(objectMetadataItems); const willDestroyEventStream = - isSseClientAvailabble && + isSseClientAvailable && isNonEmptyString(sseEventStreamId) && shouldDestroyEventStream; diff --git a/packages/twenty-front/src/modules/sse-db-event/graphql/subscriptions/OnEventSubscription.ts b/packages/twenty-front/src/modules/sse-db-event/graphql/subscriptions/OnEventSubscription.ts index a9360e8fa2..65ad4eed66 100644 --- a/packages/twenty-front/src/modules/sse-db-event/graphql/subscriptions/OnEventSubscription.ts +++ b/packages/twenty-front/src/modules/sse-db-event/graphql/subscriptions/OnEventSubscription.ts @@ -4,8 +4,8 @@ export const ON_EVENT_SUBSCRIPTION = gql` subscription OnEventSubscription($eventStreamId: String!) { onEventSubscription(eventStreamId: $eventStreamId) { eventStreamId - eventWithQueryIdsList { - event { + objectRecordEventsWithQueryIds { + objectRecordEvent { action objectNameSingular recordId @@ -20,6 +20,20 @@ export const ON_EVENT_SUBSCRIPTION = gql` } queryIds } + metadataEventsWithQueryIds { + metadataEvent { + type + metadataName + recordId + properties { + updatedFields + before + after + diff + } + } + queryIds + } } } `; diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents.ts new file mode 100644 index 0000000000..e50481eef3 --- /dev/null +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents.ts @@ -0,0 +1,54 @@ +import { dispatchMetadataOperationBrowserEvent } from '@/browser-event/utils/dispatchMetadataOperationBrowserEvent'; +import { turnSseMetadataEventsToMetadataOperationBrowserEvents } from '@/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents'; +import { useCallback } from 'react'; +import { + type AllMetadataName, + type MetadataEvent, + type MetadataEventWithQueryIds, +} from '~/generated-metadata/graphql'; + +const groupSseMetadataEventsByMetadataName = ( + sseMetadataEvents: MetadataEvent[], +): Map => { + const eventsByMetadataName = new Map(); + + for (const event of sseMetadataEvents) { + const metadataName = event.metadataName as AllMetadataName; + + const existing = eventsByMetadataName.get(metadataName) ?? []; + + eventsByMetadataName.set(metadataName, [...existing, event]); + } + + return eventsByMetadataName; +}; + +export const useDispatchMetadataEventsFromSseToBrowserEvents = < + T extends Record, +>() => { + const dispatchMetadataEventsFromSseToBrowserEvents = useCallback( + (metadataEventsWithQueryIds: MetadataEventWithQueryIds[]) => { + const sseMetadataEvents = metadataEventsWithQueryIds.map( + (item) => item.metadataEvent, + ); + + const eventsByMetadataName = + groupSseMetadataEventsByMetadataName(sseMetadataEvents); + + for (const [metadataName, events] of eventsByMetadataName) { + const metadataOperationBrowserEvents = + turnSseMetadataEventsToMetadataOperationBrowserEvents({ + metadataName, + sseMetadataEvents: events, + }); + + for (const browserEvent of metadataOperationBrowserEvents) { + dispatchMetadataOperationBrowserEvent(browserEvent); + } + } + }, + [], + ); + + return { dispatchMetadataEventsFromSseToBrowserEvents }; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts index 715632f856..e785d821f2 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts @@ -1,19 +1,19 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular'; import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { type EventWithQueryIds } from '~/generated-metadata/graphql'; +import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql'; export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => { const { objectMetadataItems } = useObjectMetadataItems(); const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback( - (eventsWithQueryIds: EventWithQueryIds[]) => { - const objectRecordEvents = eventsWithQueryIds.map((eventWithQueryIds) => { - return eventWithQueryIds.event; - }); + (objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => { + const objectRecordEvents = objectRecordEventsWithQueryIds.map( + (item) => item.objectRecordEvent, + ); const objectRecordEventsByObjectMetadataItemNameSingular = groupObjectRecordSseEventsByObjectMetadataItemNameSingular({ diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts similarity index 85% rename from packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts rename to packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts index 0d121223d1..a50a148181 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts @@ -2,14 +2,19 @@ import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQuery import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { useEffect } from 'react'; import { useRecoilCallback } from 'recoil'; -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; +import { + type MetadataGqlOperationSignature, + type RecordGqlOperationSignature, +} from 'twenty-shared/types'; -export const useListenToObjectRecordEventsForQuery = ({ +export const useListenToEventsForQuery = ({ queryId, operationSignature, }: { queryId: string; - operationSignature: RecordGqlOperationSignature; + operationSignature: + | RecordGqlOperationSignature + | MetadataGqlOperationSignature; }) => { const changeQueryIdListenState = useRecoilCallback( ({ set, snapshot }) => diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts index 31162105f8..9e6986343a 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts @@ -1,4 +1,5 @@ import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription'; +import { useDispatchMetadataEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents'; import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents'; import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents'; import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/disposeFunctionByEventStreamMapState'; @@ -23,6 +24,9 @@ export const useTriggerEventStreamCreation = () => { isCreatingSseEventStreamState, ); + const { dispatchMetadataEventsFromSseToBrowserEvents } = + useDispatchMetadataEventsFromSseToBrowserEvents(); + const { dispatchObjectRecordEventsFromSseToBrowserEvents } = useDispatchObjectRecordEventsFromSseToBrowserEvents(); @@ -73,9 +77,55 @@ export const useTriggerEventStreamCreation = () => { }, }, { + next: ( + value: ExecutionResult<{ + onEventSubscription: EventSubscription; + }>, + ) => { + if (isDefined(value?.errors)) { + captureException( + new Error( + `SSE subscription error: ${value.errors[0]?.message}`, + ), + ); + set(shouldDestroyEventStreamState, true); + + return; + } + + if (!hasReceivedFirstEvent) { + hasReceivedFirstEvent = true; + set(sseEventStreamReadyState, true); + } + + const eventSubscription = value?.data?.onEventSubscription; + + const objectRecordEventsWithQueryIds = + eventSubscription?.objectRecordEventsWithQueryIds ?? []; + + const metadataEventsWithQueryIds = + eventSubscription?.metadataEventsWithQueryIds ?? []; + + const objectRecordEvents = objectRecordEventsWithQueryIds.map( + (item) => item.objectRecordEvent, + ); + + triggerOptimisticEffectFromSseEvents({ + objectRecordEvents, + }); + + dispatchObjectRecordEventsFromSseToBrowserEvents( + objectRecordEventsWithQueryIds, + ); + + dispatchMetadataEventsFromSseToBrowserEvents( + metadataEventsWithQueryIds, + ); + }, + error: (error) => { + captureException(error); + }, complete: () => {}, - error: () => {}, - next: () => {}, }, { message: ({ data, event }) => { @@ -109,12 +159,12 @@ export const useTriggerEventStreamCreation = () => { const objectRecordEventsWithQueryIds = result?.data?.onEventSubscription - ?.eventWithQueryIdsList ?? []; + ?.objectRecordEventsWithQueryIds ?? []; const objectRecordEvents = objectRecordEventsWithQueryIds.map( - (eventWithQueryIds) => { - return eventWithQueryIds.event; + (objectRecordEventWithQueryIds) => { + return objectRecordEventWithQueryIds.objectRecordEvent; }, ); @@ -144,9 +194,9 @@ export const useTriggerEventStreamCreation = () => { setIsCreatingSseEventStream(false); }, [ + dispatchMetadataEventsFromSseToBrowserEvents, dispatchObjectRecordEventsFromSseToBrowserEvents, setIsCreatingSseEventStream, - triggerOptimisticEffectFromSseEvents, ], ); diff --git a/packages/twenty-front/src/modules/sse-db-event/states/activeQueryListenersState.ts b/packages/twenty-front/src/modules/sse-db-event/states/activeQueryListenersState.ts index bb877105b2..d215b474d4 100644 --- a/packages/twenty-front/src/modules/sse-db-event/states/activeQueryListenersState.ts +++ b/packages/twenty-front/src/modules/sse-db-event/states/activeQueryListenersState.ts @@ -1,8 +1,16 @@ -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; import { createState } from '@/ui/utilities/state/utils/createState'; +import { + type MetadataGqlOperationSignature, + type RecordGqlOperationSignature, +} from 'twenty-shared/types'; export const activeQueryListenersState = createState< - { queryId: string; operationSignature: RecordGqlOperationSignature }[] + { + queryId: string; + operationSignature: + | RecordGqlOperationSignature + | MetadataGqlOperationSignature; + }[] >({ key: 'activeQueryListenersState', defaultValue: [], diff --git a/packages/twenty-front/src/modules/sse-db-event/states/requiredQueryListenersState.ts b/packages/twenty-front/src/modules/sse-db-event/states/requiredQueryListenersState.ts index df042b7857..e788ede15b 100644 --- a/packages/twenty-front/src/modules/sse-db-event/states/requiredQueryListenersState.ts +++ b/packages/twenty-front/src/modules/sse-db-event/states/requiredQueryListenersState.ts @@ -1,8 +1,16 @@ -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; import { createState } from '@/ui/utilities/state/utils/createState'; +import { + type MetadataGqlOperationSignature, + type RecordGqlOperationSignature, +} from 'twenty-shared/types'; export const requiredQueryListenersState = createState< - { queryId: string; operationSignature: RecordGqlOperationSignature }[] + { + queryId: string; + operationSignature: + | RecordGqlOperationSignature + | MetadataGqlOperationSignature; + }[] >({ key: 'requiredQueryListenersState', defaultValue: [], diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/dispatchObjectRecordEventsWithQueryIds.ts b/packages/twenty-front/src/modules/sse-db-event/utils/dispatchObjectRecordEventsWithQueryIds.ts index 0281159f95..8fa4d8af37 100644 --- a/packages/twenty-front/src/modules/sse-db-event/utils/dispatchObjectRecordEventsWithQueryIds.ts +++ b/packages/twenty-front/src/modules/sse-db-event/utils/dispatchObjectRecordEventsWithQueryIds.ts @@ -1,22 +1,20 @@ import { type ObjectRecordEventsByQueryId } from '@/sse-db-event/types/ObjectRecordEventsByQueryId'; import { getObjectRecordEventsForQueryEventName } from '@/sse-db-event/utils/getObjectRecordEventsForQueryEventName'; import { isDefined } from 'twenty-shared/utils'; -import { type EventWithQueryIds } from '~/generated-metadata/graphql'; +import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql'; export const dispatchObjectRecordEventsWithQueryIds = ( - objectRecordEventsWithQueryIds: EventWithQueryIds[], + objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[], ) => { const objectRecordEventsByQueryId: ObjectRecordEventsByQueryId = {}; - for (const objectRecordEventWithQueryIds of objectRecordEventsWithQueryIds) { - for (const queryId of objectRecordEventWithQueryIds.queryIds) { + for (const item of objectRecordEventsWithQueryIds) { + for (const queryId of item.queryIds) { if (!isDefined(objectRecordEventsByQueryId[queryId])) { objectRecordEventsByQueryId[queryId] = []; } - objectRecordEventsByQueryId[queryId].push( - objectRecordEventWithQueryIds.event, - ); + objectRecordEventsByQueryId[queryId].push(item.objectRecordEvent); } } diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts new file mode 100644 index 0000000000..7fdea55d36 --- /dev/null +++ b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts @@ -0,0 +1,66 @@ +import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail'; +import { isDefined } from 'twenty-shared/utils'; +import { + MetadataEventAction, + type AllMetadataName, + type MetadataEvent, +} from '~/generated-metadata/graphql'; + +export const turnSseMetadataEventsToMetadataOperationBrowserEvents = < + T extends Record, +>({ + metadataName, + sseMetadataEvents, +}: { + metadataName: AllMetadataName; + sseMetadataEvents: MetadataEvent[]; +}): MetadataOperationBrowserEventDetail[] => { + return sseMetadataEvents + .map((event): MetadataOperationBrowserEventDetail | null => { + switch (event.type) { + case MetadataEventAction.CREATED: { + const createdRecord = event.properties.after; + + if (!isDefined(createdRecord)) { + return null; + } + + return { + metadataName, + operation: { + type: 'create', + createdRecord, + }, + }; + } + case MetadataEventAction.UPDATED: { + const updatedRecord = event.properties.after; + + if (!isDefined(updatedRecord)) { + return null; + } + + return { + metadataName, + operation: { + type: 'update', + updatedRecord, + updatedFields: event.properties.updatedFields ?? undefined, + }, + }; + } + case MetadataEventAction.DELETED: { + return { + metadataName, + operation: { + type: 'delete', + deletedRecordId: event.recordId, + }, + }; + } + default: + return null; + } + }) + .filter(isDefined); +}; 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 0c17615b70..de775842ec 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 @@ -1,5 +1,5 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; import { getObjectRecordOperationUpdateInputs } from '@/sse-db-event/utils/getObjectRecordOperationUpdateInputs'; import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useStopWorkflowRun.ts b/packages/twenty-front/src/modules/workflow/hooks/useStopWorkflowRun.ts index 4548a37840..88fa913dd9 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useStopWorkflowRun.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useStopWorkflowRun.ts @@ -1,7 +1,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { useStopWorkflowRunMutation } from '~/generated/graphql'; export const useStopWorkflowRun = () => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunSSESubscribeEffect.tsx index 4219433b7f..33429abbe5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunSSESubscribeEffect.tsx @@ -1,5 +1,5 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; export const WorkflowRunSSESubscribeEffect = ({ workflowRunId, @@ -8,7 +8,7 @@ export const WorkflowRunSSESubscribeEffect = ({ }) => { const queryId = `workflow-run-${workflowRunId}`; - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular: CoreObjectNameSingular.WorkflowRun, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowSSESubscribeEffect.tsx index 06d07af0bd..bd3e37b3c9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowSSESubscribeEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowSSESubscribeEffect.tsx @@ -3,8 +3,8 @@ import { useSetRecoilState } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent'; -import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery'; +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; import { shouldWorkflowRefetchRequestFamilyState } from '@/workflow/states/shouldWorkflowRefetchRequestFamilyState'; export const WorkflowSSESubscribeEffect = ({ @@ -23,7 +23,7 @@ export const WorkflowSSESubscribeEffect = ({ objectNameSingular: CoreObjectNameSingular.WorkflowVersion, }); - useListenToObjectRecordEventsForQuery({ + useListenToEventsForQuery({ queryId, operationSignature: { objectNameSingular: CoreObjectNameSingular.WorkflowVersion, diff --git a/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts b/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts index 534d6483e0..91fc285373 100644 --- a/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts +++ b/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts @@ -22,14 +22,14 @@ import { type CUSTOM_DOMAIN_DEACTIVATED_EVENT, type CustomDomainDeactivatedTrackEvent, } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated'; -import { - type MONITORING_EVENT, - type MonitoringTrackEvent, -} from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring'; import { type LOGIC_FUNCTION_EXECUTED_EVENT, type LogicFunctionExecutedTrackEvent, } from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed'; +import { + type MONITORING_EVENT, + type MonitoringTrackEvent, +} from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring'; import { type USER_SIGNUP_EVENT, type UserSignupTrackEvent, diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts index 29fa5d72da..2f2916f8d4 100644 --- a/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts @@ -8,13 +8,15 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job'; -import { AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; +import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service'; +import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; @Injectable() export class MetadataEventsToDbListener { constructor( @InjectMessageQueue(MessageQueue.webhookQueue) private readonly webhookQueueService: MessageQueueService, + private readonly workspaceEventEmitterService: WorkspaceEventEmitterService, ) {} @OnEvent('metadata.*.created') @@ -49,5 +51,9 @@ export class MetadataEventsToDbListener { >(CallWebhookJobsForMetadataJob.name, metadataEventBatch, { retryLimit: 3, }); + + if (metadataEventBatch.events.length > 0) { + await this.workspaceEventEmitterService.publish(metadataEventBatch); + } } } diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts index f7a9d1993a..1ae18b09dd 100644 --- a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts +++ b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts @@ -1,10 +1,12 @@ import { Global, Module } from '@nestjs/common'; -import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter'; import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener'; +import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter'; +import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; @Global() @Module({ + imports: [SubscriptionsModule], providers: [MetadataEventEmitter, MetadataEventsToDbListener], exports: [MetadataEventEmitter], }) diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts index 161c6b9109..e6b4335ec2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -10,6 +10,7 @@ import { FrontComponentResolver } from 'src/engine/metadata-modules/front-compon import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace TokenModule, PermissionsModule, FlatFrontComponentModule, + SubscriptionsModule, ], controllers: [FrontComponentController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index 3fa242c49b..31e63c4207 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -129,10 +129,14 @@ export class FrontComponentService { }, ); - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatFrontComponentToCreate.id, - flatEntityMaps: recomputedFlatFrontComponentMaps, - }); + const createdFlatFrontComponent = findFlatEntityByIdInFlatEntityMapsOrThrow( + { + flatEntityId: flatFrontComponentToCreate.id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }, + ); + + return createdFlatFrontComponent; } async updateOne({ @@ -200,10 +204,14 @@ export class FrontComponentService { }, ); - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatFrontComponentMaps, - }); + const updatedFlatFrontComponent = findFlatEntityByIdInFlatEntityMapsOrThrow( + { + flatEntityId: id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }, + ); + + return updatedFlatFrontComponent; } async destroyOne({ diff --git a/packages/twenty-server/src/engine/subscriptions/dtos/add-query-subscription.input.ts b/packages/twenty-server/src/engine/subscriptions/dtos/add-query-subscription.input.ts index 660fa8cfc8..2a1f3c62b1 100644 --- a/packages/twenty-server/src/engine/subscriptions/dtos/add-query-subscription.input.ts +++ b/packages/twenty-server/src/engine/subscriptions/dtos/add-query-subscription.input.ts @@ -1,7 +1,8 @@ import { Field, InputType } from '@nestjs/graphql'; import GraphQLJSON from 'graphql-type-json'; -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; + +import { type RecordOrMetadataGqlOperationSignature } from 'src/engine/subscriptions/types/event-stream-data.type'; @InputType() export class AddQuerySubscriptionInput { @@ -12,5 +13,5 @@ export class AddQuerySubscriptionInput { queryId: string; @Field(() => GraphQLJSON) - operationSignature: RecordGqlOperationSignature; + operationSignature: RecordOrMetadataGqlOperationSignature; } diff --git a/packages/twenty-server/src/engine/subscriptions/dtos/event-subscription.dto.ts b/packages/twenty-server/src/engine/subscriptions/dtos/event-subscription.dto.ts index 4fd5e57dbc..d04b0cdb10 100644 --- a/packages/twenty-server/src/engine/subscriptions/dtos/event-subscription.dto.ts +++ b/packages/twenty-server/src/engine/subscriptions/dtos/event-subscription.dto.ts @@ -1,14 +1,24 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { MetadataEventDTO } from './metadata-event.dto'; import { ObjectRecordEventDTO } from './object-record-event.dto'; -@ObjectType('EventWithQueryIds') -export class EventWithQueryIdsDTO { +@ObjectType('ObjectRecordEventWithQueryIds') +export class ObjectRecordEventWithQueryIdsDTO { @Field(() => [String]) queryIds: string[]; @Field(() => ObjectRecordEventDTO) - event: ObjectRecordEventDTO; + objectRecordEvent: ObjectRecordEventDTO; +} + +@ObjectType('MetadataEventWithQueryIds') +export class MetadataEventWithQueryIdsDTO { + @Field(() => [String]) + queryIds: string[]; + + @Field(() => MetadataEventDTO) + metadataEvent: MetadataEventDTO; } @ObjectType('EventSubscription') @@ -16,6 +26,9 @@ export class EventSubscriptionDTO { @Field(() => String) eventStreamId: string; - @Field(() => [EventWithQueryIdsDTO]) - eventWithQueryIdsList: EventWithQueryIdsDTO[]; + @Field(() => [ObjectRecordEventWithQueryIdsDTO]) + objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIdsDTO[]; + + @Field(() => [MetadataEventWithQueryIdsDTO]) + metadataEventsWithQueryIds: MetadataEventWithQueryIdsDTO[]; } diff --git a/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts b/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts new file mode 100644 index 0000000000..6b7bcdd4fe --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { ObjectRecordEventPropertiesDTO } from 'src/engine/subscriptions/dtos/object-record-event-properties.dto'; +import { MetadataEventAction } from 'src/engine/subscriptions/enums/metadata-event-action.enum'; + +@ObjectType('MetadataEvent') +export class MetadataEventDTO { + @Field(() => MetadataEventAction) + type: MetadataEventAction; + + @Field(() => String) + metadataName: string; + + @Field(() => String) + recordId: string; + + @Field(() => ObjectRecordEventPropertiesDTO) + properties: ObjectRecordEventPropertiesDTO; +} diff --git a/packages/twenty-server/src/engine/subscriptions/enums/metadata-event-action.enum.ts b/packages/twenty-server/src/engine/subscriptions/enums/metadata-event-action.enum.ts new file mode 100644 index 0000000000..9c86f90efb --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/enums/metadata-event-action.enum.ts @@ -0,0 +1,12 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum MetadataEventAction { + CREATED = 'created', + UPDATED = 'updated', + DELETED = 'deleted', +} + +registerEnumType(MetadataEventAction, { + name: 'MetadataEventAction', + description: 'Metadata Event Action', +}); diff --git a/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts b/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts index ed4496d7e1..74691ba876 100644 --- a/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts +++ b/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; @@ -15,7 +14,10 @@ import { EventStreamException, EventStreamExceptionCode, } from 'src/engine/subscriptions/event-stream.exception'; -import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type'; +import { + type EventStreamData, + type RecordOrMetadataGqlOperationSignature, +} from 'src/engine/subscriptions/types/event-stream-data.type'; @Injectable() export class EventStreamService implements OnModuleInit { @@ -180,7 +182,7 @@ export class EventStreamService implements OnModuleInit { workspaceId: string; eventStreamChannelId: string; queryId: string; - operationSignature: RecordGqlOperationSignature; + operationSignature: RecordOrMetadataGqlOperationSignature; }): Promise { const key = this.getEventStreamKey(workspaceId, eventStreamChannelId); const existing = await this.cacheStorageService.get(key); diff --git a/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts b/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts index e24daa02eb..cf26780f27 100644 --- a/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts +++ b/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts @@ -1,10 +1,17 @@ -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; +import { + type MetadataGqlOperationSignature, + type RecordGqlOperationSignature, +} from 'twenty-shared/types'; import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +export type RecordOrMetadataGqlOperationSignature = + | RecordGqlOperationSignature + | MetadataGqlOperationSignature; + export type EventStreamData = { authContext: SerializableAuthContext; workspaceId: string; - queries: Record; + queries: Record; createdAt: number; }; diff --git a/packages/twenty-server/src/engine/subscriptions/types/event-stream-payload.type.ts b/packages/twenty-server/src/engine/subscriptions/types/event-stream-payload.type.ts new file mode 100644 index 0000000000..33e9c89029 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/types/event-stream-payload.type.ts @@ -0,0 +1,13 @@ +import { type ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type'; +import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +export type EventStreamPayload = { + objectRecordEventsWithQueryIds: { + queryIds: string[]; + objectRecordEvent: ObjectRecordSubscriptionEvent; + }[]; + metadataEventsWithQueryIds: { + queryIds: string[]; + metadataEvent: MetadataEvent; + }[]; +}; diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts index 4aaff3a8ea..779bc2a1ff 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts @@ -352,8 +352,12 @@ describe('WorkspaceEventEmitterService', () => { expect(publishCall.workspaceId).toBe(workspaceId); expect(publishCall.eventStreamChannelId).toBe(streamChannelId); - expect(publishCall.payload).toHaveLength(1); - expect(publishCall.payload[0].queryIds).toContain('query-1'); + expect(publishCall.payload.objectRecordEventsWithQueryIds).toHaveLength( + 1, + ); + expect( + publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds, + ).toContain('query-1'); }); it('should not publish events when object-level read permission is denied', async () => { @@ -519,12 +523,14 @@ describe('WorkspaceEventEmitterService', () => { mockSubscriptionService.publishToEventStream as jest.Mock ).mock.calls[0][0]; - expect(publishCall.payload[0].event.properties.after).not.toHaveProperty( - 'secretField', - ); - expect(publishCall.payload[0].event.properties.after).toHaveProperty( - 'name', - ); + expect( + publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent + .properties.after, + ).not.toHaveProperty('secretField'); + expect( + publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent + .properties.after, + ).toHaveProperty('name'); }); it('should skip update events when all updated fields are restricted', async () => { @@ -658,7 +664,8 @@ describe('WorkspaceEventEmitterService', () => { mockSubscriptionService.publishToEventStream as jest.Mock ).mock.calls[0][0]; - const eventPayload = publishCall.payload[0].event; + const eventPayload = + publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent; expect(eventPayload.properties.updatedFields).toEqual(['name']); expect(eventPayload.properties.diff).not.toHaveProperty('secretField'); @@ -829,7 +836,9 @@ describe('WorkspaceEventEmitterService', () => { mockSubscriptionService.publishToEventStream as jest.Mock ).mock.calls[0][0]; - expect(publishCall.payload).toHaveLength(2); + expect(publishCall.payload.objectRecordEventsWithQueryIds).toHaveLength( + 2, + ); }); it('should handle multiple matching queries', async () => { @@ -868,8 +877,12 @@ describe('WorkspaceEventEmitterService', () => { mockSubscriptionService.publishToEventStream as jest.Mock ).mock.calls[0][0]; - expect(publishCall.payload[0].queryIds).toContain('query-1'); - expect(publishCall.payload[0].queryIds).toContain('query-2'); + expect( + publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds, + ).toContain('query-1'); + expect( + publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds, + ).toContain('query-2'); }); it('should use before record for delete events', async () => { diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/utils/__tests__/is-metadata-record-matching-filter.util.spec.ts b/packages/twenty-server/src/engine/workspace-event-emitter/utils/__tests__/is-metadata-record-matching-filter.util.spec.ts new file mode 100644 index 0000000000..df93791f6f --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-event-emitter/utils/__tests__/is-metadata-record-matching-filter.util.spec.ts @@ -0,0 +1,512 @@ +import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util'; + +const record = { + id: '1', + name: 'Test Object', + label: 'testObject', + isActive: true, + count: 42, +}; + +describe('isMetadataRecordMatchingFilter', () => { + describe('empty filters', () => { + it('should match any record when filter is empty', () => { + expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true); + }); + }); + + describe('and filter', () => { + it('should match when all sub-filters match', () => { + const filter = { + and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }], + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should not match when any sub-filter fails', () => { + const filter = { + and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }], + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false); + }); + + it('should match when and array is empty', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { and: [] }, + }), + ).toBe(true); + }); + + it('should throw when and value is not an array', () => { + expect(() => + isMetadataRecordMatchingFilter({ + record, + filter: { and: 'invalid' } as any, + }), + ).toThrow('Unexpected value for "and" filter'); + }); + }); + + describe('or filter', () => { + it('should match when any sub-filter matches', () => { + const filter = { + or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }], + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should not match when no sub-filter matches', () => { + const filter = { + or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }], + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false); + }); + + it('should match when or array is empty', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { or: [] }, + }), + ).toBe(true); + }); + + it('should treat or with an object as an and', () => { + const filter = { + or: { name: { eq: 'Test Object' } }, + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should throw when or value is neither array nor object', () => { + expect(() => + isMetadataRecordMatchingFilter({ + record, + filter: { or: 'invalid' } as any, + }), + ).toThrow('Unexpected value for "or" filter'); + }); + }); + + describe('not filter', () => { + it('should negate a matching filter', () => { + const filter = { not: { name: { eq: 'Test Object' } } }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false); + }); + + it('should negate a non-matching filter', () => { + const filter = { not: { name: { eq: 'Wrong' } } }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should match when not contains an empty object', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { not: {} }, + }), + ).toBe(true); + }); + }); + + describe('implicit and (multi-key filter)', () => { + it('should treat multiple keys as an implicit AND', () => { + const filter = { + name: { eq: 'Test Object' }, + isActive: { eq: true }, + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should fail if any key in implicit AND does not match', () => { + const filter = { + name: { eq: 'Test Object' }, + isActive: { eq: false }, + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false); + }); + }); + + describe('eq operator', () => { + it('should match equal string values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { eq: 'Test Object' } }, + }), + ).toBe(true); + }); + + it('should not match different string values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { eq: 'Other' } }, + }), + ).toBe(false); + }); + + it('should match equal boolean values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { isActive: { eq: true } }, + }), + ).toBe(true); + }); + }); + + describe('neq operator', () => { + it('should match when values are different', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { neq: 'Other' } }, + }), + ).toBe(true); + }); + + it('should not match when values are equal', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { neq: 'Test Object' } }, + }), + ).toBe(false); + }); + }); + + describe('in operator', () => { + it('should match when value is in the array', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { in: ['Test Object', 'Other'] } }, + }), + ).toBe(true); + }); + + it('should not match when value is not in the array', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { in: ['A', 'B'] } }, + }), + ).toBe(false); + }); + + it('should return false when in value is not an array', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { in: 'not-array' } } as any, + }), + ).toBe(false); + }); + }); + + describe('is operator', () => { + it('should match NULL for undefined values', () => { + expect( + isMetadataRecordMatchingFilter({ + record: { ...record, optional: undefined }, + filter: { optional: { is: 'NULL' } }, + }), + ).toBe(true); + }); + + it('should not match NULL for defined values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { is: 'NULL' } }, + }), + ).toBe(false); + }); + + it('should match NOT_NULL for defined values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { is: 'NOT_NULL' } }, + }), + ).toBe(true); + }); + + it('should not match NOT_NULL for undefined values', () => { + expect( + isMetadataRecordMatchingFilter({ + record: { ...record, optional: undefined }, + filter: { optional: { is: 'NOT_NULL' } }, + }), + ).toBe(false); + }); + }); + + describe('like operator', () => { + it('should match with wildcard prefix', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { like: '%Object' } }, + }), + ).toBe(true); + }); + + it('should match with wildcard suffix', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { like: 'Test%' } }, + }), + ).toBe(true); + }); + + it('should match with wildcards on both sides', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { like: '%est Obj%' } }, + }), + ).toBe(true); + }); + + it('should not match when pattern does not match', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { like: 'wrong%' } }, + }), + ).toBe(false); + }); + + it('should escape regex special characters in pattern', () => { + const specialRecord = { ...record, name: 'foo.bar' }; + + expect( + isMetadataRecordMatchingFilter({ + record: specialRecord, + filter: { name: { like: 'foo.bar' } }, + }), + ).toBe(true); + + // A dot in the pattern should NOT match any character + expect( + isMetadataRecordMatchingFilter({ + record: { ...record, name: 'fooXbar' }, + filter: { name: { like: 'foo.bar' } }, + }), + ).toBe(false); + }); + + it('should return false for non-string values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { like: '42' } }, + }), + ).toBe(false); + }); + }); + + describe('ilike operator', () => { + it('should match case-insensitively', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: { ilike: '%test object%' } }, + }), + ).toBe(true); + }); + + it('should escape regex special characters', () => { + const specialRecord = { ...record, name: 'foo(bar)' }; + + expect( + isMetadataRecordMatchingFilter({ + record: specialRecord, + filter: { name: { ilike: 'FOO(BAR)' } }, + }), + ).toBe(true); + }); + + it('should return false for non-string values', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { ilike: '42' } }, + }), + ).toBe(false); + }); + }); + + describe('gt operator', () => { + it('should match when value is greater', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { gt: 40 } }, + }), + ).toBe(true); + }); + + it('should not match when value is equal', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { gt: 42 } }, + }), + ).toBe(false); + }); + + it('should not match when value is less', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { gt: 50 } }, + }), + ).toBe(false); + }); + }); + + describe('gte operator', () => { + it('should match when value is greater or equal', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { gte: 42 } }, + }), + ).toBe(true); + }); + + it('should not match when value is less', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { gte: 43 } }, + }), + ).toBe(false); + }); + }); + + describe('lt operator', () => { + it('should match when value is less', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { lt: 50 } }, + }), + ).toBe(true); + }); + + it('should not match when value is equal', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { lt: 42 } }, + }), + ).toBe(false); + }); + }); + + describe('lte operator', () => { + it('should match when value is less or equal', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { lte: 42 } }, + }), + ).toBe(true); + }); + + it('should not match when value is greater', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { count: { lte: 41 } }, + }), + ).toBe(false); + }); + }); + + describe('unknown operator', () => { + it('should throw for unsupported operators', () => { + expect(() => + isMetadataRecordMatchingFilter({ + record, + filter: { name: { unknownOp: 'value' } } as any, + }), + ).toThrow('Unsupported filter operator'); + }); + }); + + describe('nested logical operators', () => { + it('should handle deeply nested and/or/not', () => { + const filter = { + and: [ + { + or: [ + { name: { eq: 'Wrong' } }, + { not: { isActive: { eq: false } } }, + ], + }, + { count: { gte: 40 } }, + ], + }; + + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + + it('should handle not with nested and', () => { + const filter = { + not: { + and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }], + }, + }; + + // name matches but count > 100 fails, so AND = false, NOT = true + expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should skip non-object field filters', () => { + // When fieldFilter is a primitive, it is skipped (returns true) + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: 'Test Object' }, + }), + ).toBe(true); + }); + + it('should skip undefined field filters', () => { + expect( + isMetadataRecordMatchingFilter({ + record, + filter: { name: undefined }, + }), + ).toBe(true); + }); + + it('should handle records with missing fields', () => { + expect( + isMetadataRecordMatchingFilter({ + record: { id: '1' }, + filter: { name: { eq: 'Test' } }, + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util.ts b/packages/twenty-server/src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util.ts new file mode 100644 index 0000000000..b9da93ac6b --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util.ts @@ -0,0 +1,198 @@ +import { isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +type MetadataFilter = Record; + +type MetadataScalarFilter = { + is?: 'NULL' | 'NOT_NULL'; + eq?: unknown; + neq?: unknown; + in?: unknown[]; + like?: string; + ilike?: string; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; + +const isEmptyFilter = (filter: MetadataFilter): boolean => + Object.keys(filter).length === 0; + +const isAndFilter = (filter: MetadataFilter): boolean => + 'and' in filter && isDefined(filter.and); + +const isOrFilter = (filter: MetadataFilter): boolean => + 'or' in filter && isDefined(filter.or); + +const isNotFilter = (filter: MetadataFilter): boolean => + 'not' in filter && isDefined(filter.not); + +const isImplicitAndFilter = (filter: MetadataFilter): boolean => + Object.keys(filter).length > 1; + +const escapeRegExp = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const isMetadataRecordMatchingFilter = ({ + record, + filter, +}: { + record: Record; + filter: MetadataFilter; +}): boolean => { + if (isEmptyFilter(filter)) { + return true; + } + + if (isImplicitAndFilter(filter)) { + return Object.entries(filter).every(([key, value]) => + isMetadataRecordMatchingFilter({ + record, + filter: { [key]: value }, + }), + ); + } + + if (isAndFilter(filter)) { + const andValue = filter.and; + + if (!Array.isArray(andValue)) { + throw new Error( + 'Unexpected value for "and" filter: ' + JSON.stringify(andValue), + ); + } + + return ( + andValue.length === 0 || + andValue.every((subFilter: MetadataFilter) => + isMetadataRecordMatchingFilter({ record, filter: subFilter }), + ) + ); + } + + if (isOrFilter(filter)) { + const orValue = filter.or; + + if (Array.isArray(orValue)) { + return ( + orValue.length === 0 || + orValue.some((subFilter: MetadataFilter) => + isMetadataRecordMatchingFilter({ record, filter: subFilter }), + ) + ); + } + + if (isObject(orValue)) { + return isMetadataRecordMatchingFilter({ + record, + filter: orValue as MetadataFilter, + }); + } + + throw new Error( + 'Unexpected value for "or" filter: ' + JSON.stringify(orValue), + ); + } + + if (isNotFilter(filter)) { + const notValue = filter.not; + + if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) { + return true; + } + + return !isMetadataRecordMatchingFilter({ + record, + filter: notValue as MetadataFilter, + }); + } + + return Object.entries(filter).every(([fieldName, fieldFilter]) => { + if (!isDefined(fieldFilter) || !isObject(fieldFilter)) { + return true; + } + + const recordValue = record[fieldName]; + + return isScalarValueMatchingFilter( + recordValue, + fieldFilter as MetadataScalarFilter, + ); + }); +}; + +const isScalarValueMatchingFilter = ( + value: unknown, + fieldFilter: MetadataScalarFilter, +): boolean => { + if ('is' in fieldFilter) { + if (fieldFilter.is === 'NULL') { + return !isDefined(value); + } + + return isDefined(value); + } + + if ('eq' in fieldFilter) { + return value === fieldFilter.eq; + } + + if ('neq' in fieldFilter) { + return value !== fieldFilter.neq; + } + + if ('in' in fieldFilter) { + if (!Array.isArray(fieldFilter.in)) { + return false; + } + + return fieldFilter.in.includes(value); + } + + if ('like' in fieldFilter) { + if (typeof value !== 'string') { + return false; + } + + const pattern = String(fieldFilter.like) + .split('%') + .map(escapeRegExp) + .join('.*'); + + return new RegExp(`^${pattern}$`).test(value); + } + + if ('ilike' in fieldFilter) { + if (typeof value !== 'string') { + return false; + } + + const pattern = String(fieldFilter.ilike) + .split('%') + .map(escapeRegExp) + .join('.*'); + + return new RegExp(`^${pattern}$`, 'i').test(value); + } + + if ('gt' in fieldFilter) { + return isDefined(value) && (value as number) > (fieldFilter.gt as number); + } + + if ('gte' in fieldFilter) { + return isDefined(value) && (value as number) >= (fieldFilter.gte as number); + } + + if ('lt' in fieldFilter) { + return isDefined(value) && (value as number) < (fieldFilter.lt as number); + } + + if ('lte' in fieldFilter) { + return isDefined(value) && (value as number) <= (fieldFilter.lte as number); + } + + throw new Error( + 'Unsupported filter operator: ' + JSON.stringify(fieldFilter), + ); +}; diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.resolver.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.resolver.ts index 3fb350aaea..664b5383f4 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.resolver.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.resolver.ts @@ -18,10 +18,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant'; import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input'; -import { - EventSubscriptionDTO, - EventWithQueryIdsDTO, -} from 'src/engine/subscriptions/dtos/event-subscription.dto'; +import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto'; import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto'; import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input'; import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input'; @@ -32,6 +29,7 @@ import { } from 'src/engine/subscriptions/event-stream.exception'; import { EventStreamService } from 'src/engine/subscriptions/event-stream.service'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; +import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type'; import { wrapAsyncIteratorWithLifecycle } from 'src/engine/workspace-event-emitter/utils/wrap-async-iterator-with-lifecycle'; import { WorkspaceEventEmitterExceptionFilter } from 'src/engine/workspace-event-emitter/workspace-event-emitter-exception.filter'; @@ -86,12 +84,13 @@ export class WorkspaceEventEmitterResolver { @Subscription(() => EventSubscriptionDTO, { nullable: true, resolve: ( - payload: EventWithQueryIdsDTO[], + payload: EventStreamPayload, variables: { eventStreamId: string }, ) => { return { eventStreamId: variables.eventStreamId, - eventWithQueryIdsList: payload, + objectRecordEventsWithQueryIds: payload.objectRecordEventsWithQueryIds, + metadataEventsWithQueryIds: payload.metadataEventsWithQueryIds, }; }, }) @@ -126,7 +125,7 @@ export class WorkspaceEventEmitterResolver { }, }); - let iterator: AsyncIterableIterator; + let iterator: AsyncIterableIterator; try { iterator = await this.subscriptionService.subscribeToEventStream({ @@ -142,7 +141,10 @@ export class WorkspaceEventEmitterResolver { } return wrapAsyncIteratorWithLifecycle(iterator, { - initialValue: [], + initialValue: { + objectRecordEventsWithQueryIds: [], + metadataEventsWithQueryIds: [], + }, onHeartbeat: () => this.eventStreamService.refreshEventStreamTTL({ workspaceId: workspace.id, 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 b08cbaac26..89b6f03c95 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 @@ -7,9 +7,16 @@ import { ObjectRecord, type ObjectsPermissionsByRoleId, type RecordGqlOperationFilter, + type RecordGqlOperationSignature, type RestrictedFieldsPermissions, } from 'twenty-shared/types'; -import { combineFilters, isDefined } from 'twenty-shared/utils'; +import { + combineFilters, + isDefined, + isMetadataGqlOperationSignature, + isNonEmptyArray, + isRecordGqlOperationSignature, +} from 'twenty-shared/utils'; import { FindOptionsRelations, ObjectLiteral } from 'typeorm'; import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; @@ -18,6 +25,7 @@ import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; 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 { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; @@ -30,7 +38,11 @@ import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhoo import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum'; import { EventStreamService } from 'src/engine/subscriptions/event-stream.service'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; -import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type'; +import { + type EventStreamData, + type RecordOrMetadataGqlOperationSignature, +} from 'src/engine/subscriptions/types/event-stream-data.type'; +import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type'; import { ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @@ -38,7 +50,9 @@ import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; +import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util'; import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name'; +import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; @Injectable() export class WorkspaceEventEmitterService { @@ -53,13 +67,29 @@ export class WorkspaceEventEmitterService { ) {} async publish( - workspaceEventBatch: WorkspaceEventBatch, + eventBatch: WorkspaceEventBatch | MetadataEventBatch, ): Promise { - const [nameSingular, operation] = workspaceEventBatch.name.split('.'); + if (!this.isMetadataEventBatch(eventBatch)) { + await this.publishToLegacyChannel(eventBatch); + } - for (const eventData of workspaceEventBatch.events) { + await this.publishToEventStreams(eventBatch); + } + + private isMetadataEventBatch( + eventBatch: WorkspaceEventBatch | MetadataEventBatch, + ): eventBatch is MetadataEventBatch { + return 'metadataName' in eventBatch; + } + + private async publishToLegacyChannel( + eventBatch: WorkspaceEventBatch, + ): Promise { + const [nameSingular, operation] = eventBatch.name.split('.'); + + for (const eventData of eventBatch.events) { const { record, updatedFields } = transformEventToWebhookEvent({ - eventName: workspaceEventBatch.name, + eventName: eventBatch.name, event: eventData, }); @@ -71,21 +101,19 @@ export class WorkspaceEventEmitterService { ...(updatedFields && { updatedFields }), }; - // Publish individual events to legacy channel (onDbEvent) await this.subscriptionService.publish({ channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL, - workspaceId: workspaceEventBatch.workspaceId, + workspaceId: eventBatch.workspaceId, payload: { onDbEvent: event }, }); } - - await this.publishToEventStreams(workspaceEventBatch); } private async publishToEventStreams( - workspaceEventBatch: WorkspaceEventBatch, + eventBatch: WorkspaceEventBatch | MetadataEventBatch, ): Promise { - const workspaceId = workspaceEventBatch.workspaceId; + const workspaceId = eventBatch.workspaceId; + const isMetadata = this.isMetadataEventBatch(eventBatch); const activeStreamIds = await this.eventStreamService.getActiveStreamIds(workspaceId); @@ -99,15 +127,12 @@ export class WorkspaceEventEmitterService { activeStreamIds, ); - const permissionsContext = await this.fetchPermissionsContext(workspaceId); - - const { flatWorkspaceMemberMaps } = - await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'flatWorkspaceMemberMaps', - ]); - const streamIdsToRemove: string[] = []; + const objectRecordStreamContext = !isMetadata + ? await this.fetchObjectRecordStreamContext(workspaceId) + : undefined; + for (const [streamChannelId, streamData] of streamsData) { if (!isDefined(streamData)) { streamIdsToRemove.push(streamChannelId); @@ -118,13 +143,25 @@ export class WorkspaceEventEmitterService { continue; } - await this.processStreamEvents( - streamChannelId, - streamData, - workspaceEventBatch, - permissionsContext, - flatWorkspaceMemberMaps, - ); + if (isMetadata) { + await this.processMetadataStreamEvents( + streamChannelId, + streamData, + eventBatch as MetadataEventBatch, + ); + } else { + if (!isDefined(objectRecordStreamContext)) { + continue; + } + + await this.processObjectRecordStreamEvents( + streamChannelId, + streamData, + eventBatch as WorkspaceEventBatch, + objectRecordStreamContext.permissionsContext, + objectRecordStreamContext.flatWorkspaceMemberMaps, + ); + } } await this.eventStreamService.removeFromActiveStreams( @@ -133,7 +170,102 @@ export class WorkspaceEventEmitterService { ); } - private async processStreamEvents( + private async fetchObjectRecordStreamContext(workspaceId: string) { + const permissionsContext = await this.fetchPermissionsContext(workspaceId); + const { flatWorkspaceMemberMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatWorkspaceMemberMaps', + ]); + + return { permissionsContext, flatWorkspaceMemberMaps }; + } + + private async processMetadataStreamEvents( + streamChannelId: string, + streamData: EventStreamData, + metadataEventBatch: MetadataEventBatch, + ): Promise { + const metadataEventsWithQueryIds: { + queryIds: string[]; + metadataEvent: MetadataEvent; + }[] = []; + + for (const metadataEvent of metadataEventBatch.events) { + const matchedQueryIds = this.getMatchingMetadataQueryIds( + streamData.queries, + metadataEvent, + ); + + if (!isNonEmptyArray(matchedQueryIds)) { + continue; + } + + metadataEventsWithQueryIds.push({ + queryIds: matchedQueryIds, + metadataEvent, + }); + } + + if (!isNonEmptyArray(metadataEventsWithQueryIds)) { + return; + } + + const payload: EventStreamPayload = { + objectRecordEventsWithQueryIds: [], + metadataEventsWithQueryIds, + }; + + await this.subscriptionService.publishToEventStream({ + workspaceId: metadataEventBatch.workspaceId, + eventStreamChannelId: streamChannelId, + payload, + }); + } + + private getMatchingMetadataQueryIds( + queries: Record, + metadataEvent: MetadataEvent, + ): string[] { + const properties = metadataEvent.properties as { + after?: Record; + before?: Record; + }; + + const record = properties?.after ?? properties?.before; + + return Object.entries(queries) + .filter(([, operationSignature]) => { + if (!isMetadataGqlOperationSignature(operationSignature)) { + return false; + } + + if (operationSignature.metadataName !== metadataEvent.metadataName) { + return false; + } + + const queryFilter = ( + operationSignature.variables as { + filter?: Record; + } + )?.filter; + + if (!isDefined(queryFilter) || Object.keys(queryFilter).length === 0) { + return true; + } + + if (!isDefined(record)) { + return false; + } + + return isMetadataRecordMatchingFilter({ + record, + filter: queryFilter, + }); + }) + .map(([queryId]) => queryId); + } + + private async processObjectRecordStreamEvents( streamChannelId: string, streamData: EventStreamData, workspaceEventBatch: WorkspaceEventBatch, @@ -169,7 +301,7 @@ export class WorkspaceEventEmitterService { const matchedEvents: { queryIds: string[]; - event: ObjectRecordEvent & { objectNameSingular: string }; + objectRecordEvent: ObjectRecordSubscriptionEvent; }[] = []; const objectNameSingular = workspaceEventBatch.objectMetadata.nameSingular; @@ -210,7 +342,7 @@ export class WorkspaceEventEmitterService { continue; } - const matchedQueryIds = this.getMatchingQueryIds( + const matchedQueryIds = this.getMatchingObjectRecordQueryIds( streamData.queries, filteredEvent, subscriberRLSFilter, @@ -224,24 +356,29 @@ export class WorkspaceEventEmitterService { matchedEvents.push({ queryIds: matchedQueryIds, - event: filteredEvent, + objectRecordEvent: filteredEvent, }); } if (matchedEvents.length > 0) { await this.enrichEventBatchWithNestedRelations({ objectMetadata: workspaceEventBatch.objectMetadata, - events: matchedEvents.map((e) => e.event), + events: matchedEvents.map((e) => e.objectRecordEvent), streamData, permissionsContext, workspaceId: workspaceEventBatch.workspaceId, roleId, }); + const payload: EventStreamPayload = { + objectRecordEventsWithQueryIds: matchedEvents, + metadataEventsWithQueryIds: [], + }; + await this.subscriptionService.publishToEventStream({ workspaceId: workspaceEventBatch.workspaceId, eventStreamChannelId: streamChannelId, - payload: matchedEvents, + payload, }); } } @@ -435,14 +572,8 @@ export class WorkspaceEventEmitterService { } as ObjectRecordSubscriptionEvent; } - private getMatchingQueryIds( - queries: Record< - string, - { - objectNameSingular: string; - variables?: { filter?: RecordGqlOperationFilter }; - } - >, + private getMatchingObjectRecordQueryIds( + queries: Record, event: ObjectRecordSubscriptionEvent, subscriberRLSFilter: RecordGqlOperationFilter | null, objectMetadata: FlatObjectMetadata, @@ -451,8 +582,12 @@ export class WorkspaceEventEmitterService { const matchedQueryIds: string[] = []; for (const [queryId, operationSignature] of Object.entries(queries)) { + if (!isRecordGqlOperationSignature(operationSignature)) { + continue; + } + if ( - this.isQueryMatchingEvent( + this.isQueryMatchingObjectRecordEvent( operationSignature, event, subscriberRLSFilter, @@ -467,11 +602,8 @@ export class WorkspaceEventEmitterService { return matchedQueryIds; } - private isQueryMatchingEvent( - operationSignature: { - objectNameSingular: string; - variables?: { filter?: RecordGqlOperationFilter }; - }, + private isQueryMatchingObjectRecordEvent( + operationSignature: RecordGqlOperationSignature, event: ObjectRecordSubscriptionEvent, subscriberRLSFilter: RecordGqlOperationFilter | null, objectMetadata: FlatObjectMetadata, @@ -485,6 +617,7 @@ export class WorkspaceEventEmitterService { after?: object; before?: object; }; + const record = properties?.after ?? properties?.before; if (!isDefined(record)) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts new file mode 100644 index 0000000000..2a91a24e03 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts @@ -0,0 +1,28 @@ +import { type AllMetadataName } from 'twenty-shared/metadata'; + +export const METADATA_EVENTS_TO_EMIT = { + frontComponent: true, + objectMetadata: true, + fieldMetadata: true, + view: true, + viewField: true, + viewFieldGroup: true, + viewGroup: true, + viewFilter: true, + viewFilterGroup: true, + role: true, + roleTarget: true, + agent: true, + skill: true, + pageLayout: true, + pageLayoutWidget: true, + pageLayoutTab: true, + commandMenuItem: true, + navigationMenuItem: true, + rowLevelPermissionPredicate: true, + rowLevelPermissionPredicateGroup: true, + index: true, + logicFunction: true, + + webhook: false, +} as const satisfies { [P in AllMetadataName]: boolean }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts index 708ea02d18..96bb32ff61 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts @@ -1,6 +1,7 @@ import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; +import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant'; import { type CreateMetadataEvent, type MetadataEvent, @@ -9,6 +10,14 @@ import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/works export const deriveMetadataEventsFromCreateAction = ( flatAction: AllFlatWorkspaceMigrationAction<'create'>, +): MetadataEvent[] => { + const events = deriveAllMetadataEventsFromCreateAction(flatAction); + + return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]); +}; + +const deriveAllMetadataEventsFromCreateAction = ( + flatAction: AllFlatWorkspaceMigrationAction<'create'>, ): MetadataEvent[] => { switch (flatAction.metadataName) { case 'fieldMetadata': { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts index 1997dab23c..48be453b13 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts @@ -5,6 +5,7 @@ import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; +import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant'; import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/flat-entity-to-scalar-flat-entity.util'; @@ -16,6 +17,18 @@ export type DeriveMetadataEventsFromDeleteActionArgs = { export const deriveMetadataEventsFromDeleteAction = ({ flatAction, allFlatEntityMaps, +}: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => { + const events = deriveAllMetadataEventsFromDeleteAction({ + flatAction, + allFlatEntityMaps, + }); + + return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]); +}; + +const deriveAllMetadataEventsFromDeleteAction = ({ + flatAction, + allFlatEntityMaps, }: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => { switch (flatAction.metadataName) { case 'fieldMetadata': diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts index e39e74ad21..accc98b81e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts @@ -7,6 +7,7 @@ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-m import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type'; import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; +import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant'; import { type CreateMetadataEvent, type DeleteMetadataEvent, @@ -61,6 +62,18 @@ const buildUpdateMetadataEvent = ({ export const deriveMetadataEventsFromUpdateAction = ({ flatAction, allFlatEntityMaps, +}: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => { + const events = deriveAllMetadataEventsFromUpdateAction({ + flatAction, + allFlatEntityMaps, + }); + + return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]); +}; + +const deriveAllMetadataEventsFromUpdateAction = ({ + flatAction, + allFlatEntityMaps, }: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => { switch (flatAction.metadataName) { case 'index': { diff --git a/packages/twenty-shared/src/types/MetadataGqlOperationSignature.ts b/packages/twenty-shared/src/types/MetadataGqlOperationSignature.ts new file mode 100644 index 0000000000..0675b9e8f2 --- /dev/null +++ b/packages/twenty-shared/src/types/MetadataGqlOperationSignature.ts @@ -0,0 +1,4 @@ +export type MetadataGqlOperationSignature = { + metadataName: string; + variables: Record; +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 6e8cb9de87..1e431d48d0 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -116,6 +116,7 @@ export type { IsNever } from './IsNever.type'; export type { IsSerializedRelation } from './IsSerializedRelation.type'; export type { LogicFunctionEvent } from './LogicFunctionEvent'; export { MessageParticipantRole } from './MessageParticipantRole'; +export type { MetadataGqlOperationSignature } from './MetadataGqlOperationSignature'; export type { ModifiedProperties } from './ModifiedProperties'; export type { NavigateOptions } from './NavigateOptions'; export type { NonNullableRequired } from './NonNullableRequired'; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 7c30247d06..baf2fd9bd5 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -166,7 +166,9 @@ export { export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string'; +export { isMetadataGqlOperationSignature } from './typeguard/isMetadataGqlOperationSignature'; export { isPlainObject } from './typeguard/isPlainObject'; +export { isRecordGqlOperationSignature } from './typeguard/isRecordGqlOperationSignature'; export { throwIfNotDefined } from './typeguard/throwIfNotDefined'; export { absoluteUrlSchema } from './url/absoluteUrlSchema'; export { buildSignedPath } from './url/buildSignedPath'; diff --git a/packages/twenty-shared/src/utils/typeguard/isMetadataGqlOperationSignature.ts b/packages/twenty-shared/src/utils/typeguard/isMetadataGqlOperationSignature.ts new file mode 100644 index 0000000000..e1a23c4026 --- /dev/null +++ b/packages/twenty-shared/src/utils/typeguard/isMetadataGqlOperationSignature.ts @@ -0,0 +1,9 @@ +import { type MetadataGqlOperationSignature } from '../../types/MetadataGqlOperationSignature'; +import { type RecordGqlOperationSignature } from '../../types/RecordGqlOperationSignature'; + +export const isMetadataGqlOperationSignature = ( + operationSignature: + | RecordGqlOperationSignature + | MetadataGqlOperationSignature, +): operationSignature is MetadataGqlOperationSignature => + 'metadataName' in operationSignature; diff --git a/packages/twenty-shared/src/utils/typeguard/isRecordGqlOperationSignature.ts b/packages/twenty-shared/src/utils/typeguard/isRecordGqlOperationSignature.ts new file mode 100644 index 0000000000..717629ec18 --- /dev/null +++ b/packages/twenty-shared/src/utils/typeguard/isRecordGqlOperationSignature.ts @@ -0,0 +1,9 @@ +import { type MetadataGqlOperationSignature } from '../../types/MetadataGqlOperationSignature'; +import { type RecordGqlOperationSignature } from '../../types/RecordGqlOperationSignature'; + +export const isRecordGqlOperationSignature = ( + operationSignature: + | RecordGqlOperationSignature + | MetadataGqlOperationSignature, +): operationSignature is RecordGqlOperationSignature => + 'objectNameSingular' in operationSignature;