Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 36934df3bc fix: handle missing morph relation field in timeline activity for custom objects
https://sonarly.com/issue/15168?type=bug

The timeline activity worker crashes when processing events for custom objects whose morph relation field is missing from the timelineActivity object metadata, causing timeline activity recording to fail silently for affected workspaces.

Fix: **Fix:** Added a metadata validation check in `TimelineActivityRepository.upsertTimelineActivities()` that verifies the morph relation field exists in the `timelineActivity` object metadata before constructing queries with it.

**What changed:**

1. **`timeline-activity.repository.ts`**: Added `hasTimelineActivityMorphRelationField()` method that loads the workspace's flat entity maps, finds the `timelineActivity` object metadata, and checks whether the expected join column name exists in the field maps. If the field is missing, `upsertTimelineActivities()` logs a warning and returns early instead of letting `formatData` crash.

2. **`object-metadata-repository.module.ts`**: Updated the factory to inject `WorkspaceManyOrAllFlatEntityMapsCacheService` into repositories created through this module, so `TimelineActivityRepository` can access workspace metadata for validation.

**Why this approach:**
- Fixes the crash at the right layer (timeline repository) rather than suppressing errors in the shared `formatData` utility
- Preserves the strict validation in `formatData` (which catches other metadata inconsistencies)
- Logs a clear warning with actionable guidance (run workspace:sync-metadata)
- Matches the team's pattern of using `WorkspaceManyOrAllFlatEntityMapsCacheService` for metadata lookups (already used in `TimelineActivityService`)
2026-03-16 12:51:23 +00:00
2 changed files with 72 additions and 5 deletions
@@ -7,6 +7,8 @@ import {
import { capitalize } from 'twenty-shared/utils';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { metadataToRepositoryMapping } from 'src/engine/object-metadata-repository/metadata-to-repository.mapping';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@@ -31,16 +33,29 @@ export class ObjectMetadataRepositoryModule {
provide: `${capitalize(
convertClassNameToObjectMetadataName(objectMetadata.name),
)}Repository`,
useFactory: (globalWorkspaceOrmManager: GlobalWorkspaceOrmManager) => {
return new repositoryClass(globalWorkspaceOrmManager);
useFactory: (
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) => {
return new repositoryClass(
globalWorkspaceOrmManager,
workspaceManyOrAllFlatEntityMapsCacheService,
);
},
inject: [GlobalWorkspaceOrmManager],
inject: [
GlobalWorkspaceOrmManager,
WorkspaceManyOrAllFlatEntityMapsCacheService,
],
};
});
return {
module: ObjectMetadataRepositoryModule,
imports: [WorkspaceDataSourceModule, TwentyORMModule],
imports: [
WorkspaceDataSourceModule,
TwentyORMModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [...providers],
exports: providers,
};
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { type ObjectRecord } from 'twenty-shared/types';
import { In, MoreThan } from 'typeorm';
import { objectRecordDiffMerge } from 'src/engine/core-modules/event-emitter/utils/object-record-diff-merge';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type TimelineActivityPayload } from 'src/modules/timeline/types/timeline-activity-payload';
@@ -20,8 +22,11 @@ type TimelineActivityPayloadWorkspaceIdAndObjectSingularName = {
@Injectable()
export class TimelineActivityRepository {
private readonly logger = new Logger(TimelineActivityRepository.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async upsertTimelineActivities({
@@ -29,6 +34,23 @@ export class TimelineActivityRepository {
workspaceId,
payloads,
}: TimelineActivityPayloadWorkspaceIdAndObjectSingularName) {
const timelineActivityPropertyName =
await this.getTimelineActivityPropertyName(objectSingularName);
const hasMorphRelationField =
await this.hasTimelineActivityMorphRelationField(
workspaceId,
timelineActivityPropertyName,
);
if (!hasMorphRelationField) {
this.logger.warn(
`Skipping timeline activity upsert: morph relation field "${timelineActivityPropertyName}" is missing in timelineActivity metadata for object "${objectSingularName}" in workspace ${workspaceId}. Run workspace:sync-metadata to fix.`,
);
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
@@ -196,4 +218,34 @@ export class TimelineActivityRepository {
private async getTimelineActivityPropertyName(objectSingularName: string) {
return `${buildTimelineActivityRelatedMorphFieldMetadataName(objectSingularName)}Id`;
}
private async hasTimelineActivityMorphRelationField(
workspaceId: string,
joinColumnName: string,
): Promise<boolean> {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const timelineActivityObjectMetadata = Object.values(
flatObjectMetadataMaps.byId,
).find(
(objectMetadata) => objectMetadata?.nameSingular === 'timelineActivity',
);
if (!timelineActivityObjectMetadata) {
return false;
}
const { fieldIdByJoinColumnName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
timelineActivityObjectMetadata,
);
return isDefined(fieldIdByJoinColumnName[joinColumnName]);
}
}