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:
+8
-2
@@ -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,
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
+28
-4
@@ -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,
|
||||
|
||||
+83
-49
@@ -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}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('FilesFieldFile')
|
||||
export class FilesFieldFileDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
path: string;
|
||||
|
||||
@Field()
|
||||
size: number;
|
||||
|
||||
@Field(() => Date, { nullable: false })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
url: string;
|
||||
}
|
||||
+10
-6
@@ -6,7 +6,7 @@ import { Readable } from 'stream';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Like, Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
@@ -52,7 +53,7 @@ export class FilesFieldService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
fieldMetadataId: string;
|
||||
}): Promise<FileEntity> {
|
||||
}): Promise<FilesFieldFileDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file,
|
||||
filename,
|
||||
@@ -78,7 +79,7 @@ export class FilesFieldService {
|
||||
},
|
||||
});
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
resourcePath: `${fieldMetadata.universalIdentifier}/${name}`,
|
||||
mimeType,
|
||||
@@ -91,6 +92,11 @@ export class FilesFieldService {
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.signFileUrl({ fileId, workspaceId }),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFilesFieldFile({
|
||||
@@ -127,10 +133,8 @@ export class FilesFieldService {
|
||||
const file = await this.fileRepository.findOneOrFail({
|
||||
where: {
|
||||
id: fileId,
|
||||
path: Like(`${FileFolder.FilesField}/%`),
|
||||
workspaceId,
|
||||
application: {
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto';
|
||||
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
@@ -24,7 +24,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
export class FilesFieldResolver {
|
||||
constructor(private readonly filesFieldService: FilesFieldService) {}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
@Mutation(() => FilesFieldFileDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
async uploadFilesFieldFile(
|
||||
@AuthWorkspace()
|
||||
@@ -37,7 +37,7 @@ export class FilesFieldResolver {
|
||||
nullable: false,
|
||||
})
|
||||
fieldMetadataId: string,
|
||||
): Promise<FileDTO> {
|
||||
): Promise<FilesFieldFileDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
export const extractFileIdFromUrl = (url: string): string | null => {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathname = parsedUrl.pathname;
|
||||
const isLinkExternal = !pathname.startsWith('/files-field/');
|
||||
|
||||
if (isLinkExternal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileId = pathname.match(/files-field\/([^/]+)/)?.[1];
|
||||
|
||||
return isDefined(fileId) && isValidUuid(fileId) ? fileId : null;
|
||||
};
|
||||
Reference in New Issue
Block a user