File storage cleaning (#18381)

- Remove feature flag
- Remove legacy methods in file-upload and file-service
- Migrate AI Chat to new file management

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2026-03-04 23:46:03 +01:00
committed by GitHub
parent c41a8e2b23
commit 26f0a416a1
117 changed files with 859 additions and 3239 deletions
@@ -15,7 +15,6 @@ 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 { 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';
@@ -43,7 +42,6 @@ export class CommonResultGettersService {
constructor(
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeObjectHandlers();
this.initializeFieldHandlers();
@@ -55,11 +53,7 @@ export class CommonResultGettersService {
['person', new PersonQueryResultGetterHandler(this.fileService)],
[
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
this.fileUrlService,
),
new WorkspaceMemberQueryResultGetterHandler(this.fileUrlService),
],
]);
}
@@ -75,11 +69,7 @@ export class CommonResultGettersService {
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(
this.fileService,
this.fileUrlService,
this.featureFlagService,
),
new RichTextV2FieldQueryResultGetterHandler(this.fileUrlService),
],
]);
}
@@ -1,9 +1,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 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';
const baseRecord: ObjectRecord = {
@@ -20,28 +18,17 @@ const richTextFieldMetadata = [
},
] as FlatFieldMetadata[];
const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
const mockFileUrlService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
signFileByIdUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileUrlService;
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,
mockFileUrlService,
mockFeatureFlagService,
);
handler = new RichTextV2FieldQueryResultGetterHandler(mockFileUrlService);
});
afterEach(() => {
@@ -169,54 +156,6 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
});
});
describe('should sign internal image URLs', () => {
it('when image block has an internal attachment URL (legacy path)', async () => {
jest
.spyOn(mockFeatureFlagService, 'isFeatureEnabled')
.mockResolvedValue(false);
const imageBlock = {
type: 'image',
props: {
name: 'photo.jpg',
url: 'https://my-domain.twenty.com/files/attachment/some-token/photo.jpg',
caption: '',
},
children: [],
};
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([imageBlock]),
},
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual({
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{
...imageBlock,
props: {
...imageBlock.props,
url: 'https://my-domain.twenty.com/files/signed-path',
},
},
]),
},
});
});
});
describe('should handle multiple RICH_TEXT_V2 fields', () => {
it('when record has multiple rich text fields', async () => {
const multiFieldMetadata = [
@@ -1,5 +1,4 @@
import {
FeatureFlagKey,
FieldMetadataType,
FileFolder,
type ObjectRecord,
@@ -8,10 +7,8 @@ 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 { 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 FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -36,11 +33,7 @@ const parseBlocknoteJsonSafely = (
export class RichTextV2FieldQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {}
constructor(private readonly fileUrlService: FileUrlService) {}
async handle(
record: ObjectRecord,
@@ -55,11 +48,6 @@ 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;
@@ -77,7 +65,6 @@ export class RichTextV2FieldQueryResultGetterHandler
const signedBlocks = this.signBlocknoteImageUrls(
blocknoteBlocks,
workspaceId,
isFilesFieldMigrated,
);
record[field.name] = {
@@ -92,69 +79,32 @@ export class RichTextV2FieldQueryResultGetterHandler
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,
FileFolder.FilesField,
);
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
...block,
props: {
...block.props,
url,
},
};
}
if (block.type !== 'image' || !block.props?.url) {
if (!isDefined(block.props?.url)) {
return block;
}
let url: URL;
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
try {
url = new URL(block.props.url);
} catch {
if (!isDefined(fileIdFromUrl)) {
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}`,
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
...block,
props: {
...block.props,
url: `${process.env.SERVER_URL}/files/${signedPath}`,
url,
},
};
});