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
This commit is contained in:
Weiko
2026-06-01 16:32:08 +02:00
committed by GitHub
parent 381ca32055
commit b9e5ff2065
7 changed files with 98 additions and 28 deletions
@@ -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<TimelineActivity>({
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)
@@ -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]);
};
@@ -115,6 +115,13 @@ export class CommonSelectFieldsHelper {
flatEntityId: flatField.relationTargetObjectMetadataId,
});
if (
!objectsPermissions[relationTargetObjectMetadata.id]
?.canReadObjectRecords
) {
continue;
}
const relationFieldSelectFields = getAllSelectableFields({
restrictedFields:
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
@@ -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<T>,
action: DatabaseEventAction,
) {
if (
batchEvent.objectMetadata.universalIdentifier ===
STANDARD_OBJECTS.timelineActivity.universalIdentifier
) {
await this.objectRecordEventPublisher.publish(batchEvent);
return;
}
const isAuditLogBatchEvent = batchEvent.objectMetadata?.isAuditLogged;
const batchEventForWebhook = {
@@ -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,
@@ -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<T, DatabaseEventAction> | undefined => {
if (
objectMetadataItem.universalIdentifier ===
STANDARD_OBJECTS.timelineActivity.universalIdentifier
) {
return;
}
const objectMetadataNameSingular = objectMetadataItem.nameSingular;
let events: (
@@ -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,