File - Migrate core pictures (workspace and member logo) + workflow attachments (#17924)

- Create a common file-by-id download controller
- Create core picture module with resolver and logic to handle
workspaceLogo and workspaceMemberProfilePicture update
- Create workflow file module (same)
- Data migration
This commit is contained in:
Etienne
2026-02-17 15:42:50 +00:00
committed by GitHub
parent 963f2de864
commit 163c1175cb
85 changed files with 2212 additions and 335 deletions
@@ -6,6 +6,7 @@ import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folde
import { FavoriteModule } from 'src/modules/favorite/favorite.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
import { WorkspaceMemberModule } from 'src/modules/workspace-member/workspace-member.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
WorkflowModule,
FavoriteFolderModule,
FavoriteModule,
WorkspaceMemberModule,
],
providers: [],
exports: [],
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import {
ObjectRecordDeleteEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-shared/database-events';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class WorkspaceMemberAvatarFileDeletionListener {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly fileCorePictureService: FileCorePictureService,
) {}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.UPDATED)
async handleUpdate(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
if (
!(await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
payload.workspaceId,
))
) {
return;
}
const fileIdsToDelete = this.getFileIdsToDeleteFromUpdateEvent(payload);
this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED)
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DELETED)
async handleDestroyOrDeleteEvent(
payload: WorkspaceEventBatch<
| ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
const fileIdsToDelete =
this.getFileIdsToDeleteFromDestroyOrDeleteEvent(payload);
await this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
private async deleteCorePictures(
fileIds: string[],
workspaceId: string,
): Promise<void> {
for (const fileId of fileIds) {
await this.fileCorePictureService.deleteCorePicture({
workspaceId,
fileId,
});
}
}
private getFileIdsToDeleteFromUpdateEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
>,
): string[] {
return payload.events
.map((event) => {
const beforeAvatarUrl = event.properties.before.avatarUrl;
if (!isDefined(beforeAvatarUrl)) {
return undefined;
}
const beforeFileId = extractFileIdFromUrl(
beforeAvatarUrl,
FileFolder.CorePicture,
);
const afterFileId = extractFileIdFromUrl(
event.properties.after.avatarUrl ?? '',
FileFolder.CorePicture,
);
return beforeFileId !== afterFileId ? beforeFileId : undefined;
})
.filter(isDefined);
}
private getFileIdsToDeleteFromDestroyOrDeleteEvent(
payload:
| WorkspaceEventBatch<
ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
>
| WorkspaceEventBatch<
ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
): string[] {
return payload.events
.map((event) =>
isDefined(event.properties.before.avatarUrl)
? extractFileIdFromUrl(
event.properties.before.avatarUrl,
FileFolder.CorePicture,
)
: undefined,
)
.filter(isDefined);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceMemberAvatarFileDeletionListener } from 'src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener';
@Module({
imports: [TwentyORMModule, FeatureFlagModule, FileModule],
providers: [WorkspaceMemberAvatarFileDeletionListener],
})
export class WorkspaceMemberModule {}