fix: use findOne instead of findOneOrFail in deleteCorePicture and await deletion in handleUpdate

https://sonarly.com/issue/15460?type=bug

`deleteCorePicture` uses `findOneOrFail` to look up a `FileEntity` before deleting it, which throws an unhandled `EntityNotFoundError` when the file record doesn't exist. Additionally, the `handleUpdate` listener fires this deletion without `await`, turning the error into an unhandled promise rejection.

Fix: **Two changes to fix the unhandled `EntityNotFoundError`:**

1. **`file-core-picture.service.ts`**: Changed `findOneOrFail` to `findOne` in `deleteCorePicture`, with an early return and `logger.warn` when the file record doesn't exist. File deletion is a cleanup operation — a missing file record (e.g., from incomplete data migration or prior deletion) should not crash the server. This follows the same pattern used in `fetchImageBufferFromUrl` in the same file (added in commit 4c001778c2).

2. **`workspace-member-avatar-file-deletion.listener.ts`**: Added missing `await` to `this.deleteCorePictures(...)` in `handleUpdate`. Without `await`, any rejection becomes an unhandled promise rejection (confirmed by Sentry mechanism `auto.node.onunhandledrejection`). The `handleDestroyOrDeleteEvent` handler already correctly awaits this call.
This commit is contained in:
Sonarly Claude Code
2026-03-17 01:40:40 +00:00
parent e7422fb8c3
commit cea450cb9b
5 changed files with 16 additions and 74 deletions
@@ -161,7 +161,7 @@ export class FileCorePictureService {
fileId: string;
workspaceId: string;
}): Promise<void> {
const file = await this.fileRepository.findOneOrFail({
const file = await this.fileRepository.findOne({
where: {
id: fileId,
path: Like(`${FileFolder.CorePicture}/%`),
@@ -169,6 +169,14 @@ export class FileCorePictureService {
},
});
if (!isDefined(file)) {
this.logger.warn(
`Core picture file not found for deletion — fileId: ${fileId}, workspaceId: ${workspaceId}`,
);
return;
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -21,10 +20,6 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/
@Injectable()
@WorkspaceCache('flatViewFieldMaps')
export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvider<FlatViewFieldMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewFieldMapCacheService.name,
);
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
@@ -78,30 +73,9 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
const viewFieldGroupIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(viewFieldGroups);
// Parallel queries without a shared transaction can observe different
// snapshots under READ COMMITTED isolation. A concurrent FieldMetadata
// hard-delete (with FK CASCADE on ViewField) that commits between the
// ViewField and FieldMetadata queries leaves orphaned ViewField rows
// in the result set. Filter them out to avoid failing the entire cache.
const consistentViewFields = viewFields.filter((viewFieldEntity) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(
viewFieldEntity.fieldMetadataId,
),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewField ${viewFieldEntity.id}: FieldMetadata ${viewFieldEntity.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewFieldMaps = createEmptyFlatEntityMaps();
for (const viewFieldEntity of consistentViewFields) {
for (const viewFieldEntity of viewFields) {
const flatViewField = fromViewFieldEntityToFlatViewField({
entity: viewFieldEntity,
applicationIdToUniversalIdentifierMap,
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -21,10 +20,6 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/
@Injectable()
@WorkspaceCache('flatViewFilterMaps')
export class WorkspaceFlatViewFilterMapCacheService extends WorkspaceCacheProvider<FlatViewFilterMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewFilterMapCacheService.name,
);
constructor(
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
@@ -78,25 +73,9 @@ export class WorkspaceFlatViewFilterMapCacheService extends WorkspaceCacheProvid
const viewIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(views);
const consistentViewFilters = viewFilters.filter((viewFilterEntity) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(
viewFilterEntity.fieldMetadataId,
),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewFilter ${viewFilterEntity.id}: FieldMetadata ${viewFilterEntity.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewFilterMaps = createEmptyFlatEntityMaps();
for (const viewFilterEntity of consistentViewFilters) {
for (const viewFilterEntity of viewFilters) {
const flatViewFilter = fromViewFilterEntityToFlatViewFilter({
entity: viewFilterEntity,
applicationIdToUniversalIdentifierMap,
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -20,10 +19,6 @@ import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/uti
@Injectable()
@WorkspaceCache('flatViewSortMaps')
export class WorkspaceFlatViewSortMapCacheService extends WorkspaceCacheProvider<FlatViewSortMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewSortMapCacheService.name,
);
constructor(
@InjectRepository(ViewSortEntity)
private readonly viewSortRepository: Repository<ViewSortEntity>,
@@ -68,23 +63,9 @@ export class WorkspaceFlatViewSortMapCacheService extends WorkspaceCacheProvider
const fieldMetadataIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(fieldMetadatas);
const consistentViewSorts = existingViewSorts.filter((viewSort) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(viewSort.fieldMetadataId),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewSort ${viewSort.id}: FieldMetadata ${viewSort.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewSortMaps = createEmptyFlatEntityMaps();
for (const viewSort of consistentViewSorts) {
for (const viewSort of existingViewSorts) {
const flatViewSort = fromViewSortEntityToFlatViewSort({
entity: viewSort,
applicationIdToUniversalIdentifierMap,
@@ -29,7 +29,7 @@ export class WorkspaceMemberAvatarFileDeletionListener {
) {
const fileIdsToDelete = this.getFileIdsToDeleteFromUpdateEvent(payload);
this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
await this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED)