From b9e5ff206512e08b321ecb2be3cee58b36269ce6 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 1 Jun 2026 16:32:08 +0200 Subject: [PATCH] fix: broadcast timeline activities to live SSE subscriptions (#21104) ## Context Timeline activities never updated in real time. They were explicitly excluded from the database-event pipeline (formatTwentyOrmEventToDatabaseBatchEvent early-returned for the timelineActivity object), so no SSE event was ever broadcast, and the frontend timeline only refreshed on mount/manual refetch. ## Implementation Backend - Feat: Stop dropping timeline-activity events in formatTwentyOrmEventToDatabaseBatchEvent. Instead route them through EntityEventsToDbListener, which publishes them directly to live subscriptions (but still skipping webhook/audit handling). - Fix: Harden ObjectRecordEventPublisher: wrap nested-relation enrichment in try/catch so a failure broadcasts the event without relations instead of dropping it (logs a warning). - Fix: Skip unreadable relation targets in CommonSelectFieldsHelper when the role lacks canReadObjectRecords, preventing errors while computing selected fields. - Fix: Support MORPH_RELATION alongside RELATION in RLS row-level permission predicate matching (timeline activities use morph targets). Frontend - Feat: useTimelineActivities now registers the timeline query with the SSE system via useListenToEventsForQuery and refetches on incoming timeline-activity record operations. - Feat: Add a skip option to useListenToEventsForQuery so the listener isn't registered when the object has no timeline field. ## Test https://github.com/user-attachments/assets/ed1d1c66-d6ea-434d-ac9c-9b83d2b78338 Note: "UpdatedBy" seems to be listen to and visible in the timeline activity summary, this is probably a bug that we want to fix --- .../hooks/useTimelineActivities.ts | 54 ++++++++++++++++--- .../hooks/useListenToEventsForQuery.ts | 8 ++- .../common-select-fields-helper.ts | 7 +++ .../listeners/entity-events-to-db.listener.ts | 10 ++++ .../object-record-event-publisher.ts | 33 ++++++++---- ...-orm-event-to-database-batch-event.util.ts | 8 --- ...rls-row-level-permission-predicate.util.ts | 6 ++- 7 files changed, 98 insertions(+), 28 deletions(-) diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts index 39c25ebece..9d993e2b27 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts @@ -1,10 +1,17 @@ +import { useCallback, useMemo } from 'react'; + import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/useLinkedObjectsTitle'; import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { + CoreObjectNameSingular, + type RecordGqlOperationFilter, +} from 'twenty-shared/types'; import { capitalize, isDefined } from 'twenty-shared/utils'; // do we need to test this? @@ -13,6 +20,15 @@ export const useTimelineActivities = ( ) => { const targetableObjectFieldIdName = `target${capitalize(targetableObject.targetObjectNameSingular)}Id`; + const filter: RecordGqlOperationFilter = useMemo( + () => ({ + [targetableObjectFieldIdName]: { + eq: targetableObject.id, + }, + }), + [targetableObjectFieldIdName, targetableObject.id], + ); + const { objectMetadataItem: timelineActivityMetadata } = useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.TimelineActivity, @@ -38,14 +54,11 @@ export const useTimelineActivities = ( records: timelineActivities, loading: loadingTimelineActivities, fetchMoreRecords, + refetch, } = useFindManyRecords({ skip: !hasTimelineActivityField, objectNameSingular: CoreObjectNameSingular.TimelineActivity, - filter: { - [targetableObjectFieldIdName]: { - eq: targetableObject.id, - }, - }, + filter, orderBy: [ { createdAt: 'DescNullsFirst', @@ -55,6 +68,35 @@ export const useTimelineActivities = ( fetchPolicy: 'cache-and-network', }); + const operationSignature = useMemo( + () => ({ + objectNameSingular: CoreObjectNameSingular.TimelineActivity, + variables: { + filter, + }, + }), + [filter], + ); + + useListenToEventsForQuery({ + queryId: `timeline-activities-${targetableObject.targetObjectNameSingular}-${targetableObject.id}`, + operationSignature, + skip: !hasTimelineActivityField, + }); + + const handleTimelineActivityOperation = useCallback(() => { + if (!hasTimelineActivityField) { + return; + } + + refetch(); + }, [hasTimelineActivityField, refetch]); + + useListenToObjectRecordOperationBrowserEvent({ + onObjectRecordOperationBrowserEvent: handleTimelineActivityOperation, + objectMetadataItemId: timelineActivityMetadata.id, + }); + const activityIds = timelineActivities .filter((timelineActivity) => timelineActivity.name.match(/note|task/i)) .map((timelineActivity) => timelineActivity.linkedRecordId) diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts index 24a5ac9fbf..399bb2b605 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToEventsForQuery.ts @@ -8,19 +8,25 @@ import { export const useListenToEventsForQuery = ({ queryId, operationSignature, + skip = false, }: { queryId: string; operationSignature: | RecordGqlOperationSignature | MetadataGqlOperationSignature; + skip?: boolean; }) => { const { changeQueryIdListenState } = useChangeQueryListenState(); useEffect(() => { + if (skip) { + return; + } + changeQueryIdListenState(true, queryId, operationSignature); return () => { changeQueryIdListenState(false, queryId, operationSignature); }; - }, [changeQueryIdListenState, queryId, operationSignature]); + }, [changeQueryIdListenState, queryId, operationSignature, skip]); }; diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts index 9d84c5be9f..c88d410da5 100644 --- a/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts +++ b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts @@ -115,6 +115,13 @@ export class CommonSelectFieldsHelper { flatEntityId: flatField.relationTargetObjectMetadataId, }); + if ( + !objectsPermissions[relationTargetObjectMetadata.id] + ?.canReadObjectRecords + ) { + continue; + } + const relationFieldSelectFields = getAllSelectableFields({ restrictedFields: objectsPermissions[relationTargetObjectMetadata.id].restrictedFields, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts index 5bfc58ef7a..a46fc380ab 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts @@ -9,6 +9,7 @@ import { type ObjectRecordRestoreEvent, type ObjectRecordUpdateEvent, } from 'twenty-shared/database-events'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; @@ -68,6 +69,15 @@ export class EntityEventsToDbListener { batchEvent: WorkspaceEventBatch, action: DatabaseEventAction, ) { + if ( + batchEvent.objectMetadata.universalIdentifier === + STANDARD_OBJECTS.timelineActivity.universalIdentifier + ) { + await this.objectRecordEventPublisher.publish(batchEvent); + + return; + } + const isAuditLogBatchEvent = batchEvent.objectMetadata?.isAuditLogged; const batchEventForWebhook = { diff --git a/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts index 12b8ba28b1..6c7f941b81 100644 --- a/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants'; import { type ObjectRecordEvent } from 'twenty-shared/database-events'; @@ -50,6 +50,8 @@ import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/ @Injectable() export class ObjectRecordEventPublisher { + private readonly logger = new Logger(ObjectRecordEventPublisher.name); + constructor( private readonly subscriptionService: SubscriptionService, private readonly eventStreamService: EventStreamService, @@ -219,16 +221,25 @@ export class ObjectRecordEventPublisher { } if (matchedEvents.length > 0) { - await this.enrichEventBatchWithNestedRelations({ - objectMetadata: workspaceEventBatch.objectMetadata, - events: matchedEvents.map( - (matchedEvent) => matchedEvent.objectRecordEvent, - ), - streamData, - permissionsContext, - workspaceId: workspaceEventBatch.workspaceId, - roleId, - }); + try { + await this.enrichEventBatchWithNestedRelations({ + objectMetadata: workspaceEventBatch.objectMetadata, + events: matchedEvents.map( + (matchedEvent) => matchedEvent.objectRecordEvent, + ), + streamData, + permissionsContext, + workspaceId: workspaceEventBatch.workspaceId, + roleId, + }); + } catch (error) { + this.logger.warn( + `Failed to enrich nested relations for ${workspaceEventBatch.name} subscription event, broadcasting without them: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } const payload: EventStreamPayload = { objectRecordEventsWithQueryIds: matchedEvents, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts index 93d57fa711..080f5b6061 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts @@ -7,7 +7,6 @@ import { ObjectRecordUpsertEvent, type ObjectRecordDiff, } from 'twenty-shared/database-events'; -import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { assertUnreachable, isDefined, @@ -47,13 +46,6 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < recordsAfter?: T[]; recordsBefore?: T[]; }): DatabaseBatchEventInput | undefined => { - if ( - objectMetadataItem.universalIdentifier === - STANDARD_OBJECTS.timelineActivity.universalIdentifier - ) { - return; - } - const objectMetadataNameSingular = objectMetadataItem.nameSingular; let events: ( diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts index 71f06a8198..7c900f120d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts @@ -208,7 +208,8 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ objectFields.find((field) => field.name === filterKey) ?? objectFields.find( (field) => - field.type === FieldMetadataType.RELATION && + (field.type === FieldMetadataType.RELATION || + field.type === FieldMetadataType.MORPH_RELATION) && computeMorphOrRelationFieldJoinColumnName({ name: field.name }) === filterKey, ); @@ -411,7 +412,8 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ }); }); } - case FieldMetadataType.RELATION: { + case FieldMetadataType.RELATION: + case FieldMetadataType.MORPH_RELATION: { const isJoinColumn = computeMorphOrRelationFieldJoinColumnName({ name: objectMetadataField.name,