Files - Migrate attachments in activities (#17808)

As attachment files have migrated from fullPath to file files field,
need to migrate richText logic to fit to new attachment file handling +
data migration
This commit is contained in:
Etienne
2026-02-13 10:11:23 +01:00
committed by GitHub
parent 90a30263ac
commit 5c2c588885
31 changed files with 798 additions and 141 deletions
@@ -15,11 +15,12 @@ import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/c
import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler';
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 { 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 { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import {
buildFieldMapsFromFlatObjectMetadata,
@@ -42,6 +43,7 @@ export class CommonResultGettersService {
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeObjectHandlers();
this.initializeFieldHandlers();
@@ -69,7 +71,11 @@ export class CommonResultGettersService {
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(this.fileService),
new RichTextV2FieldQueryResultGetterHandler(
this.fileService,
this.filesFieldService,
this.featureFlagService,
),
],
]);
}
@@ -1,6 +1,8 @@
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 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';
@@ -22,12 +24,24 @@ const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
const mockFilesFieldService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FilesFieldService;
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockReturnValue(true),
} as unknown as FeatureFlagService;
describe('RichTextV2FieldQueryResultGetterHandler', () => {
let handler: RichTextV2FieldQueryResultGetterHandler;
beforeEach(() => {
process.env.SERVER_URL = 'https://my-domain.twenty.com';
handler = new RichTextV2FieldQueryResultGetterHandler(mockFileService);
handler = new RichTextV2FieldQueryResultGetterHandler(
mockFileService,
mockFilesFieldService,
mockFeatureFlagService,
);
});
afterEach(() => {
@@ -113,7 +127,11 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{ type: 'paragraph', text: 'Hello, world!' },
{
type: 'paragraph',
props: {},
children: [{ text: 'Hello, world!' }],
},
]),
},
};
@@ -152,7 +170,11 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
});
describe('should sign internal image URLs', () => {
it('when image block has an internal attachment URL', async () => {
it('when image block has an internal attachment URL (legacy path)', async () => {
jest
.spyOn(mockFeatureFlagService, 'isFeatureEnabled')
.mockResolvedValue(false);
const imageBlock = {
type: 'image',
props: {
@@ -206,7 +228,9 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([{ type: 'paragraph', text: 'Hello' }]),
blocknote: JSON.stringify([
{ type: 'paragraph', props: {}, children: [{ text: 'Hello' }] },
]),
},
description: {
markdown: null,
@@ -3,58 +3,16 @@ 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 { 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';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RichTextBlock = Record<string, any>;
const signBlocknoteImageUrls = (
blocknoteBlocks: RichTextBlock[],
workspaceId: string,
fileService: FileService,
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (block.type !== 'image' || !block.props?.url) {
return block;
}
let url: URL;
try {
url = new URL(block.props.url);
} catch {
return block;
}
const pathname = url.pathname;
const isLinkExternal = !pathname.startsWith('/files/attachment/');
if (isLinkExternal) {
return block;
}
const fileName = pathname.match(/files\/attachment\/(?:.+)\/(.+)$/)?.[1];
if (!isDefined(fileName)) {
return block;
}
const signedPath = fileService.signFileUrl({
url: `attachment/${fileName}`,
workspaceId,
});
return {
...block,
props: {
...block.props,
url: `${process.env.SERVER_URL}/files/${signedPath}`,
},
};
});
};
const parseBlocknoteJsonSafely = (
blocknoteJson: string,
): RichTextBlock[] | null => {
@@ -74,7 +32,11 @@ const parseBlocknoteJsonSafely = (
export class RichTextV2FieldQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly featureFlagService: FeatureFlagService,
) {}
async handle(
record: ObjectRecord,
@@ -89,6 +51,11 @@ export class RichTextV2FieldQueryResultGetterHandler
return record;
}
const isFilesFieldMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId,
);
for (const field of richTextV2Fields) {
const fieldValue = record[field.name];
const blocknoteJson = fieldValue?.blocknote;
@@ -103,10 +70,10 @@ export class RichTextV2FieldQueryResultGetterHandler
continue;
}
const signedBlocks = signBlocknoteImageUrls(
const signedBlocks = this.signBlocknoteImageUrls(
blocknoteBlocks,
workspaceId,
this.fileService,
isFilesFieldMigrated,
);
record[field.name] = {
@@ -117,4 +84,71 @@ export class RichTextV2FieldQueryResultGetterHandler
return record;
}
signBlocknoteImageUrls = (
blocknoteBlocks: RichTextBlock[],
workspaceId: string,
isFilesFieldMigrated: boolean,
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (isFilesFieldMigrated && isDefined(block.props?.url)) {
const fileIdFromUrl = extractFileIdFromUrl(block.props.url);
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.filesFieldService.signFileUrl({
fileId: fileIdFromUrl,
workspaceId,
});
return {
...block,
props: {
...block.props,
url,
},
};
}
if (block.type !== 'image' || !block.props?.url) {
return block;
}
let url: URL;
try {
url = new URL(block.props.url);
} catch {
return block;
}
const pathname = url.pathname;
const isLinkExternal = !pathname.startsWith('/files/attachment/');
if (isLinkExternal) {
return block;
}
const fileName = pathname.match(/files\/attachment\/(?:.+)\/(.+)$/)?.[1];
if (!isDefined(fileName)) {
return block;
}
const signedPath = this.fileService.signFileUrl({
url: `attachment/${fileName}`,
workspaceId,
});
return {
...block,
props: {
...block.props,
url: `${process.env.SERVER_URL}/files/${signedPath}`,
},
};
});
};
}