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
@@ -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 = {