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:
+48
-6
@@ -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]);
|
||||
};
|
||||
|
||||
+7
@@ -115,6 +115,13 @@ export class CommonSelectFieldsHelper {
|
||||
flatEntityId: flatField.relationTargetObjectMetadataId,
|
||||
});
|
||||
|
||||
if (
|
||||
!objectsPermissions[relationTargetObjectMetadata.id]
|
||||
?.canReadObjectRecords
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationFieldSelectFields = getAllSelectableFields({
|
||||
restrictedFields:
|
||||
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
|
||||
|
||||
+10
@@ -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 = {
|
||||
|
||||
+22
-11
@@ -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,
|
||||
|
||||
-8
@@ -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: (
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user