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,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,