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 16:42:50 +01:00
committed by GitHub
parent 963f2de864
commit 163c1175cb
85 changed files with 2212 additions and 335 deletions
@@ -16,7 +16,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -42,7 +42,7 @@ export class CommonResultGettersService {
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeObjectHandlers();
@@ -55,7 +55,11 @@ export class CommonResultGettersService {
['person', new PersonQueryResultGetterHandler(this.fileService)],
[
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(this.fileService),
new WorkspaceMemberQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
this.fileUrlService,
),
],
]);
}
@@ -67,13 +71,13 @@ export class CommonResultGettersService {
>([
[
FieldMetadataType.FILES,
new FilesFieldQueryResultGetterHandler(this.filesFieldService),
new FilesFieldQueryResultGetterHandler(this.fileUrlService),
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(
this.fileService,
this.filesFieldService,
this.fileUrlService,
this.featureFlagService,
),
],
@@ -2,7 +2,7 @@ import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -24,9 +24,9 @@ const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
const mockFilesFieldService = {
const mockFileUrlService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FilesFieldService;
} as unknown as FileUrlService;
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockReturnValue(true),
@@ -39,7 +39,7 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
process.env.SERVER_URL = 'https://my-domain.twenty.com';
handler = new RichTextV2FieldQueryResultGetterHandler(
mockFileService,
mockFilesFieldService,
mockFileUrlService,
mockFeatureFlagService,
);
});
@@ -1,16 +1,20 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import {
FieldMetadataType,
FileFolder,
type ObjectRecord,
} from 'twenty-shared/types';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { isFileOutputArray } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard';
import type { SignedFileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export class FilesFieldQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly filesFieldService: FilesFieldService) {}
constructor(private readonly fileUrlService: FileUrlService) {}
async handle(
record: ObjectRecord,
@@ -35,9 +39,10 @@ export class FilesFieldQueryResultGetterHandler
const signedFilesFieldValue: SignedFileOutput[] = [];
for (const file of filesFieldValue) {
const url = this.filesFieldService.signFileUrl({
const url = this.fileUrlService.signFileByIdUrl({
fileId: file.fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
});
signedFilesFieldValue.push({
@@ -1,11 +1,15 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import {
FieldMetadataType,
FileFolder,
type ObjectRecord,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -34,7 +38,7 @@ export class RichTextV2FieldQueryResultGetterHandler
{
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -92,15 +96,19 @@ export class RichTextV2FieldQueryResultGetterHandler
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (isFilesFieldMigrated && isDefined(block.props?.url)) {
const fileIdFromUrl = extractFileIdFromUrl(block.props.url);
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.filesFieldService.signFileUrl({
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
@@ -13,7 +13,6 @@ import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-que
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
@@ -35,7 +34,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
UserRoleModule,
ApiKeyModule,
FileModule,
FilesFieldModule,
ViewModule,
ViewFilterModule,
ViewFilterGroupModule,
@@ -1,12 +1,23 @@
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export class WorkspaceMemberQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}
constructor(
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileUrlService: FileUrlService,
) {}
async handle(
workspaceMember: WorkspaceMemberWorkspaceEntity,
@@ -16,6 +27,33 @@ export class WorkspaceMemberQueryResultGetterHandler
return workspaceMember;
}
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId,
)
) {
const fileId = extractFileIdFromUrl(
workspaceMember.avatarUrl,
FileFolder.CorePicture,
);
if (!isDefined(fileId)) {
return workspaceMember;
}
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...workspaceMember,
avatarUrl: signedUrl,
};
}
const signedPath = this.fileService.signFileUrl({
url: workspaceMember.avatarUrl,
workspaceId,