From 5c2c58888540123cd95b0e3e7b7cf2d74dbe36ce Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:11:23 +0100 Subject: [PATCH] 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 --- .../src/generated-metadata/graphql.ts | 14 +- .../blocks/components/FileBlock.tsx | 8 +- .../components/ActivityRichTextEditor.tsx | 3 +- .../files/components/AttachmentRow.tsx | 7 +- .../files/hooks/useUploadAttachmentFile.tsx | 11 +- .../filterAttachmentsToRestore.test.ts | 25 +- ...tivityAttachmentIdsAndNameToUpdate.test.ts | 4 +- .../getActivityAttachmentIdsToDelete.test.ts | 2 + ...etActivityAttachmentPathsToRestore.test.ts | 2 + .../utils/__tests__/getAttachmentPath.test.ts | 18 - .../modules/activities/utils/compareUrls.ts | 18 +- .../utils/filterAttachmentsToRestore.ts | 19 +- ...getActivityAttachmentIdsAndNameToUpdate.ts | 7 +- .../utils/getActivityAttachmentIdsToDelete.ts | 7 +- .../getActivityAttachmentPathsToRestore.ts | 7 +- .../activities/utils/getAttachmentPath.ts | 4 + .../activities/utils/getAttachmentUrl.ts | 16 + .../graphql/mutations/uploadFilesFieldFile.ts | 1 + .../input/editor/hooks/useAttachmentSync.ts | 15 +- ...y-rich-text-attachment-file-ids.command.ts | 472 ++++++++++++++++++ .../1-18-migrate-attachment-files.command.ts | 18 +- ...-18-migrate-person-avatar-files.command.ts | 11 +- .../1-18-upgrade-version-command.module.ts | 5 + .../upgrade.command.ts | 6 + .../common-result-getters.service.ts | 10 +- ...-field-query-result-getter.handler.spec.ts | 32 +- ...xt-v2-field-query-result-getter.handler.ts | 132 +++-- .../files-field/dtos/files-field-file.dto.ts | 21 + .../file/files-field/files-field.service.ts | 16 +- .../resolvers/files-field.resolver.ts | 6 +- .../utils/extract-file-id-from-url.util.ts | 22 + 31 files changed, 798 insertions(+), 141 deletions(-) delete mode 100644 packages/twenty-front/src/modules/activities/utils/__tests__/getAttachmentPath.test.ts create mode 100644 packages/twenty-front/src/modules/activities/utils/getAttachmentUrl.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b547b4bcca0..b345ee9341b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1637,6 +1637,15 @@ export type FilesConfiguration = { configurationType: WidgetConfigurationType; }; +export type FilesFieldFile = { + __typename?: 'FilesFieldFile'; + createdAt: Scalars['DateTime']; + id: Scalars['UUID']; + path: Scalars['String']; + size: Scalars['Float']; + url: Scalars['String']; +}; + export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['UUID']; @@ -2287,7 +2296,7 @@ export type Mutation = { uploadApplicationFile: File; /** @deprecated Use uploadFilesFieldFile instead */ uploadFile: SignedFile; - uploadFilesFieldFile: File; + uploadFilesFieldFile: FilesFieldFile; uploadImage: SignedFile; uploadWorkspaceLogo: SignedFile; uploadWorkspaceMemberProfilePicture: SignedFile; @@ -5688,7 +5697,7 @@ export type UploadFilesFieldFileMutationVariables = Exact<{ }>; -export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } }; +export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'FilesFieldFile', id: string, path: string, size: number, createdAt: string, url: string } }; export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }; @@ -10314,6 +10323,7 @@ export const UploadFilesFieldFileDocument = gql` path size createdAt + url } } `; diff --git a/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx b/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx index 9e3916fcc33..0a5a3e1437e 100644 --- a/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx @@ -71,11 +71,9 @@ export const FileBlock = createReactBlockSpec( editor.updateBlock(block.id, { props: { ...block.props, - ...{ - url: fileUrl, - fileCategory: getFileType(file.name), - name: file.name, - }, + url: fileUrl, + fileCategory: getFileType(file.name), + name: file.name, }, }); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index eb69a2c820e..7091c248c91 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -139,10 +139,9 @@ export const ActivityRichTextEditor = ({ if (!canCreateActivity) { setCanCreateActivity(true); } - persistBodyDebounced(prepareBodyWithSignedUrls(activityBody)); }, - [persistBodyDebounced, setCanCreateActivity, canCreateActivity], + [canCreateActivity, persistBodyDebounced, setCanCreateActivity], ); const handleBodyChange = useRecoilCallback( diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx index bbdf40cd34c..a6630505e80 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx @@ -15,11 +15,10 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useState } from 'react'; -import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; import { FileIcon } from '@/file/components/FileIcon'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; -import { t } from '@lingui/core/macro'; import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui/display'; import { isNavigationModifierPressed } from 'twenty-ui/utilities'; import { @@ -107,11 +106,9 @@ export const AttachmentRow = ({ : attachment.fileCategory; const fileUrl = isFilesFieldMigrated - ? attachment.file?.[0]?.url + ? (attachment.file?.[0]?.url as string) // TODO : fix attachment.file type after Files field migration : attachment.fullPath; - assertIsDefinedOrThrow(fileUrl, new Error(t`File URL is not defined`)); - const { destroyOneRecord: destroyOneAttachment } = useDestroyOneRecord({ objectNameSingular: CoreObjectNameSingular.Attachment, }); diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx index fd95d723c74..74c15be50fb 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx @@ -50,6 +50,7 @@ export const useUploadAttachmentFile = () => { ) => { let attachmentPath: string; let fileId: string | undefined; + let fileUrl: string | undefined; if (isFilesFieldMigrated) { assertIsDefinedOrThrow( @@ -69,6 +70,7 @@ export const useUploadAttachmentFile = () => { attachmentPath = uploadedFile.path; fileId = uploadedFile.id; + fileUrl = uploadedFile.url; } else { const result = await uploadFile({ variables: { @@ -93,7 +95,7 @@ export const useUploadAttachmentFile = () => { const attachmentToCreate = { name: file.name, - fullPath: attachmentPath, + fullPath: isFilesFieldMigrated ? null : attachmentPath, fileCategory: getFileType(file.name), [targetableObjectFieldIdName]: targetableObject.id, ...(isFilesFieldMigrated && isDefined(fileId) @@ -110,7 +112,12 @@ export const useUploadAttachmentFile = () => { const createdAttachment = await createOneAttachment(attachmentToCreate); - return { attachmentAbsoluteURL: createdAttachment.fullPath }; + return { + attachmentAbsoluteURL: isFilesFieldMigrated + ? fileUrl + : createdAttachment.fullPath, + attachmentFileId: fileId, + }; }; return { uploadAttachmentFile }; diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/filterAttachmentsToRestore.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/filterAttachmentsToRestore.test.ts index 10849ae4e0d..dfcbc06cdd0 100644 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/filterAttachmentsToRestore.test.ts +++ b/packages/twenty-front/src/modules/activities/utils/__tests__/filterAttachmentsToRestore.test.ts @@ -9,18 +9,22 @@ describe('filterAttachmentsToRestore', () => { fullPath: 'https://exemple.com/test.txt', }, ] as Attachment[]; - const attachmentIdsToRestore = filterAttachmentsToRestore( - [], + const attachmentIdsToRestore = filterAttachmentsToRestore({ + attachmentPathsToRestore: [], softDeletedAttachments, - ); + isFilesFieldMigrated: false, + }); expect(attachmentIdsToRestore).toEqual([]); }); it('should not return any ids if there are no soft deleted attachments', () => { - const attachmentIdsToRestore = filterAttachmentsToRestore( - ['https://exemple.com/files/attachment/test.txt'], - [], - ); + const attachmentIdsToRestore = filterAttachmentsToRestore({ + attachmentPathsToRestore: [ + 'https://exemple.com/files/attachment/test.txt', + ], + softDeletedAttachments: [], + isFilesFieldMigrated: false, + }); expect(attachmentIdsToRestore).toEqual([]); }); @@ -35,10 +39,11 @@ describe('filterAttachmentsToRestore', () => { fullPath: 'https://exemple.com/files/images/test2.txt', }, ] as Attachment[]; - const attachmentIdsToRestore = filterAttachmentsToRestore( - ['https://exemple.com/files/images/test.txt'], + const attachmentIdsToRestore = filterAttachmentsToRestore({ + attachmentPathsToRestore: ['https://exemple.com/files/images/test.txt'], softDeletedAttachments, - ); + isFilesFieldMigrated: false, + }); expect(attachmentIdsToRestore).toEqual(['1']); }); }); diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsAndNameToUpdate.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsAndNameToUpdate.test.ts index a6762f93574..25c72b7ff9b 100644 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsAndNameToUpdate.test.ts +++ b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsAndNameToUpdate.test.ts @@ -33,7 +33,7 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => { }, ]); const attachmentIdsAndNameToUpdate = - getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments); + getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments, false); expect(attachmentIdsAndNameToUpdate).toEqual([]); }); @@ -68,7 +68,7 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => { }, ]); const attachmentIdsAndNameToUpdate = - getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments); + getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments, false); expect(attachmentIdsAndNameToUpdate).toEqual([{ id: '2', name: 'image4' }]); }); }); diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsToDelete.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsToDelete.test.ts index 85623b3405b..623d6fd9d8c 100644 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsToDelete.test.ts +++ b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentIdsToDelete.test.ts @@ -37,6 +37,7 @@ describe('getActivityAttachmentIdsToDelete', () => { newActivityBody, attachments, oldActivityBody, + false, ); expect(attachmentIdsToDelete).toEqual([]); }); @@ -72,6 +73,7 @@ describe('getActivityAttachmentIdsToDelete', () => { newActivityBody, attachments, oldActivityBody, + false, ); expect(attachmentIdsToDelete).toEqual(['2']); }); diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentPathsToRestore.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentPathsToRestore.test.ts index c27bed4eceb..88eeef3d228 100644 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentPathsToRestore.test.ts +++ b/packages/twenty-front/src/modules/activities/utils/__tests__/getActivityAttachmentPathsToRestore.test.ts @@ -17,6 +17,7 @@ describe('getActivityAttachmentPathsToRestore', () => { const attachmentPathsToRestore = getActivityAttachmentPathsToRestore( newActivityBody, oldActivityAttachments, + false, ); expect(attachmentPathsToRestore).toEqual([]); }); @@ -43,6 +44,7 @@ describe('getActivityAttachmentPathsToRestore', () => { const attachmentPathsToRestore = getActivityAttachmentPathsToRestore( newActivityBody, oldActivityAttachments, + false, ); expect(attachmentPathsToRestore).toEqual([ 'https://example.com/files/images/test2.txt', diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getAttachmentPath.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getAttachmentPath.test.ts deleted file mode 100644 index 80c19488fdf..00000000000 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getAttachmentPath.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { getAttachmentPath } from '@/activities/utils/getAttachmentPath'; - -describe('getAttachmentPath', () => { - it('should extract the correct path from a locally stored file URL with token', () => { - const token = 'dybszrrxvgrtefeidgybxzfxzr'; - const res = getAttachmentPath( - `https://server.com/files/attachment/${token}/image.jpg?queryParam=value`, - ); - expect(res).toEqual('https://server.com/files/attachment/image.jpg'); - }); - - it('should extract the correct path from a regular file URL', () => { - const res = getAttachmentPath( - 'https://exemple.com/files/images/image.jpg?queryParam=value', - ); - expect(res).toEqual('https://exemple.com/files/images/image.jpg'); - }); -}); diff --git a/packages/twenty-front/src/modules/activities/utils/compareUrls.ts b/packages/twenty-front/src/modules/activities/utils/compareUrls.ts index 8d745d971ce..8e778e20c90 100644 --- a/packages/twenty-front/src/modules/activities/utils/compareUrls.ts +++ b/packages/twenty-front/src/modules/activities/utils/compareUrls.ts @@ -4,11 +4,15 @@ export const compareUrls = ( firstAttachmentUrl: string, secondAttachmentUrl: string, ): boolean => { - const urlA = new URL(firstAttachmentUrl); - const urlB = new URL(secondAttachmentUrl); - if (urlA.hostname !== urlB.hostname) return false; - return ( - getAttachmentPath(firstAttachmentUrl) === - getAttachmentPath(secondAttachmentUrl) - ); + try { + const urlA = new URL(firstAttachmentUrl); + const urlB = new URL(secondAttachmentUrl); + if (urlA.hostname !== urlB.hostname) return false; + return ( + getAttachmentPath(firstAttachmentUrl) === + getAttachmentPath(secondAttachmentUrl) + ); + } catch { + return firstAttachmentUrl === secondAttachmentUrl; + } }; diff --git a/packages/twenty-front/src/modules/activities/utils/filterAttachmentsToRestore.ts b/packages/twenty-front/src/modules/activities/utils/filterAttachmentsToRestore.ts index 8c8f7f892fd..6e59ccf1006 100644 --- a/packages/twenty-front/src/modules/activities/utils/filterAttachmentsToRestore.ts +++ b/packages/twenty-front/src/modules/activities/utils/filterAttachmentsToRestore.ts @@ -1,14 +1,23 @@ import { type Attachment } from '@/activities/files/types/Attachment'; import { compareUrls } from '@/activities/utils/compareUrls'; +import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl'; -export const filterAttachmentsToRestore = ( - attachmentPathsToRestore: string[], - softDeletedAttachments: Attachment[], -) => { +export const filterAttachmentsToRestore = ({ + attachmentPathsToRestore, + softDeletedAttachments, + isFilesFieldMigrated, +}: { + attachmentPathsToRestore: string[]; + softDeletedAttachments: Attachment[]; + isFilesFieldMigrated: boolean; +}) => { return softDeletedAttachments .filter((attachment) => attachmentPathsToRestore.some((path) => - compareUrls(attachment.fullPath, path), + compareUrls( + getAttachmentUrl({ attachment, isFilesFieldMigrated }), + path, + ), ), ) .map((attachment) => attachment.id); diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsAndNameToUpdate.ts b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsAndNameToUpdate.ts index da0364faa3c..276eed9f5f9 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsAndNameToUpdate.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsAndNameToUpdate.ts @@ -4,11 +4,13 @@ import { type AttachmentInfo, getActivityAttachmentPathsAndName, } from '@/activities/utils/getActivityAttachmentPathsAndName'; +import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl'; import { isDefined } from 'twenty-shared/utils'; export const getActivityAttachmentIdsAndNameToUpdate = ( newActivityBody: string, oldActivityAttachments: Attachment[] = [], + isFilesFieldMigrated: boolean, ) => { const activityAttachmentsNameAndPaths = getActivityAttachmentPathsAndName(newActivityBody); @@ -17,7 +19,10 @@ export const getActivityAttachmentIdsAndNameToUpdate = ( return activityAttachmentsNameAndPaths.reduce( (acc: Partial[], activity: AttachmentInfo) => { const foundActivity = oldActivityAttachments.find((attachment) => - compareUrls(attachment.fullPath, activity.path), + compareUrls( + getAttachmentUrl({ attachment, isFilesFieldMigrated }), + activity.path, + ), ); if (isDefined(foundActivity) && foundActivity.name !== activity.name) { acc.push({ id: foundActivity.id, name: activity.name }); diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsToDelete.ts b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsToDelete.ts index 4bab2de1ae3..8042bca8eca 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsToDelete.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentIdsToDelete.ts @@ -1,11 +1,13 @@ import { type Attachment } from '@/activities/files/types/Attachment'; import { compareUrls } from '@/activities/utils/compareUrls'; import { getActivityAttachmentPathsAndName } from '@/activities/utils/getActivityAttachmentPathsAndName'; +import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl'; export const getActivityAttachmentIdsToDelete = ( newActivityBody: string, oldActivityAttachments: Attachment[] = [], oldActivityBody: string, + isFilesFieldMigrated: boolean, ) => { if (oldActivityAttachments.length === 0) return []; @@ -27,7 +29,10 @@ export const getActivityAttachmentIdsToDelete = ( return oldActivityAttachments .filter((attachment) => pathsToDelete.some((pathToDelete) => - compareUrls(attachment.fullPath, pathToDelete), + compareUrls( + getAttachmentUrl({ attachment, isFilesFieldMigrated }), + pathToDelete, + ), ), ) .map((attachment) => attachment.id); diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentPathsToRestore.ts b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentPathsToRestore.ts index 1de28373cff..d65c6e855c3 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentPathsToRestore.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityAttachmentPathsToRestore.ts @@ -1,10 +1,12 @@ import { type Attachment } from '@/activities/files/types/Attachment'; import { compareUrls } from '@/activities/utils/compareUrls'; import { getActivityAttachmentPathsAndName } from '@/activities/utils/getActivityAttachmentPathsAndName'; +import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl'; export const getActivityAttachmentPathsToRestore = ( newActivityBody: string, oldActivityAttachments: Attachment[], + isFilesFieldMigrated: boolean, ) => { const newActivityAttachmentPaths = getActivityAttachmentPathsAndName(newActivityBody); @@ -13,7 +15,10 @@ export const getActivityAttachmentPathsToRestore = ( .filter( (newActivity) => !oldActivityAttachments.some((attachment) => - compareUrls(newActivity.path, attachment.fullPath), + compareUrls( + newActivity.path, + getAttachmentUrl({ attachment, isFilesFieldMigrated }), + ), ), ) .map((activity) => activity.path); diff --git a/packages/twenty-front/src/modules/activities/utils/getAttachmentPath.ts b/packages/twenty-front/src/modules/activities/utils/getAttachmentPath.ts index 5c18efcdeba..c057fd892a5 100644 --- a/packages/twenty-front/src/modules/activities/utils/getAttachmentPath.ts +++ b/packages/twenty-front/src/modules/activities/utils/getAttachmentPath.ts @@ -1,4 +1,8 @@ export const getAttachmentPath = (attachmentFullPath: string) => { + if (attachmentFullPath.includes('/files-field/')) { + return attachmentFullPath?.split('?')[0]; + } + if (!attachmentFullPath.includes('/files/')) { return attachmentFullPath?.split('?')[0]; } diff --git a/packages/twenty-front/src/modules/activities/utils/getAttachmentUrl.ts b/packages/twenty-front/src/modules/activities/utils/getAttachmentUrl.ts new file mode 100644 index 00000000000..e229897df1f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/getAttachmentUrl.ts @@ -0,0 +1,16 @@ +import { type Attachment } from '@/activities/files/types/Attachment'; + +export const getAttachmentUrl = ({ + attachment, + isFilesFieldMigrated, +}: { + attachment: Attachment; + isFilesFieldMigrated: boolean; +}): string => { + if (isFilesFieldMigrated) { + //TODO : add minimumFile settings + set it for attachment.file files field + exception invariance check here + return attachment.file?.[0]?.url as string; + } + + return attachment.fullPath as string; +}; diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts index fea82be603b..22eeb6e08b8 100644 --- a/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts +++ b/packages/twenty-front/src/modules/file/graphql/mutations/uploadFilesFieldFile.ts @@ -7,6 +7,7 @@ export const UPLOAD_FILES_FIELD_FILE = gql` path size createdAt + url } } `; diff --git a/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts b/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts index 5c90fe71e9d..cea97b1b988 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts +++ b/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts @@ -8,8 +8,13 @@ import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const useAttachmentSync = (attachments: Attachment[]) => { + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({ objectNameSingular: CoreObjectNameSingular.Attachment, }); @@ -42,6 +47,7 @@ export const useAttachmentSync = (attachments: Attachment[]) => { newBody, attachments, previousBodyOrEmptyArray, + isFilesFieldMigrated, ); if (attachmentIdsToDelete.length > 0) { @@ -53,16 +59,18 @@ export const useAttachmentSync = (attachments: Attachment[]) => { const attachmentPathsToRestore = getActivityAttachmentPathsToRestore( newBody, attachments, + isFilesFieldMigrated, ); if (attachmentPathsToRestore.length > 0) { const softDeletedAttachments = (await findSoftDeletedAttachments()) as Attachment[]; - const attachmentIdsToRestore = filterAttachmentsToRestore( + const attachmentIdsToRestore = filterAttachmentsToRestore({ attachmentPathsToRestore, - softDeletedAttachments ?? [], - ); + softDeletedAttachments: softDeletedAttachments ?? [], + isFilesFieldMigrated, + }); await restoreAttachments({ idsToRestore: attachmentIdsToRestore, @@ -72,6 +80,7 @@ export const useAttachmentSync = (attachments: Attachment[]) => { const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate( newBody, attachments, + isFilesFieldMigrated, ); for (const attachmentToUpdate of attachmentsToUpdate) { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts new file mode 100644 index 00000000000..e861b0eb854 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts @@ -0,0 +1,472 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FileFolder } from 'twenty-shared/types'; +import { + extractFolderPathFilenameAndTypeOrThrow, + isDefined, +} from 'twenty-shared/utils'; +import { DataSource, Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; +import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; +import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity'; + +type RichTextBlock = Record; + +@Command({ + name: 'upgrade:1-18:migrate-activity-rich-text-attachment-file-ids', + description: + 'Migrate activity rich text blocks to include attachmentFileId from attachment.file field', +}) +export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileStorageService: FileStorageService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly applicationService: ApplicationService, + private readonly filesFieldService: FilesFieldService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isFilesFieldMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + workspaceId, + ); + + if (isFilesFieldMigrated) { + this.logger.log( + `Files field already migrated for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting activity rich text attachment file IDs migration for workspace ${workspaceId}`, + ); + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const attachmentObjectMetadata = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: STANDARD_OBJECTS.attachment.universalIdentifier, + }); + + if (!isDefined(attachmentObjectMetadata)) { + this.logger.warn( + `Attachment object metadata not found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const attachmentFileFieldMetadata = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifier: + STANDARD_OBJECTS.attachment.fields.file.universalIdentifier, + }); + + if (!isDefined(attachmentFileFieldMetadata)) { + this.logger.error( + `Failed to find attachment file field metadata for workspace ${workspaceId}`, + ); + + throw new Error( + `Failed to find attachment file field metadata for workspace ${workspaceId}`, + ); + } + + const attachmentRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'attachment', + { shouldBypassPermissionChecks: true }, + ); + + const noteRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'note', + { shouldBypassPermissionChecks: true }, + ); + + const taskRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'task', + { shouldBypassPermissionChecks: true }, + ); + + const fileRepository = this.coreDataSource.getRepository(FileEntity); + + await this.migrateActivityTable({ + activityRepository: noteRepository, + attachmentRepository, + fileRepository, + activityType: 'note', + targetColumnName: 'targetNoteId', + workspaceId, + twentyStandardApplication: twentyStandardFlatApplication, + fileFieldMetadata: attachmentFileFieldMetadata, + isDryRun, + }); + + await this.migrateActivityTable({ + activityRepository: taskRepository, + attachmentRepository, + fileRepository, + activityType: 'task', + targetColumnName: 'targetTaskId', + workspaceId, + twentyStandardApplication: twentyStandardFlatApplication, + fileFieldMetadata: attachmentFileFieldMetadata, + isDryRun, + }); + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_FILES_FIELD_MIGRATED], + workspaceId, + ); + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed activity rich text attachment file IDs migration for workspace ${workspaceId}`, + ); + } + + private async migrateActivityTable({ + activityRepository, + attachmentRepository, + fileRepository, + activityType, + targetColumnName, + workspaceId, + twentyStandardApplication, + fileFieldMetadata, + isDryRun, + }: { + activityRepository: + | WorkspaceRepository + | WorkspaceRepository; + attachmentRepository: Awaited< + ReturnType + >; + fileRepository: Repository; + activityType: 'note' | 'task'; + targetColumnName: 'targetNoteId' | 'targetTaskId'; + workspaceId: string; + twentyStandardApplication: { id: string; universalIdentifier: string }; + fileFieldMetadata: { universalIdentifier: string }; + isDryRun: boolean; + }): Promise { + this.logger.log( + `Migrating ${activityType} rich text for workspace ${workspaceId}`, + ); + + const activities = await activityRepository.find(); + + this.logger.log( + `Found ${activities.length} ${activityType}(s) to process in workspace ${workspaceId}`, + ); + + for (const activity of activities) { + const bodyV2 = activity.bodyV2; + + if (!isDefined(bodyV2?.blocknote)) { + continue; + } + + let blocknote: RichTextBlock[]; + + try { + blocknote = JSON.parse(bodyV2.blocknote); + } catch (error) { + this.logger.warn( + `Failed to parse bodyV2.blocknote for ${activityType} ${activity.id}: ${error.message}`, + ); + continue; + } + + const needsMigration = blocknote.some((block: RichTextBlock) => { + const props = (block.props as Record) || {}; + const url = props.url as string | undefined; + + return isDefined(url) && !isDefined(extractFileIdFromUrl(url)); + }); + + if (!needsMigration) { + continue; + } + + const urlToFileIdMap = await this.buildUrlToFileIdMap( + attachmentRepository, + targetColumnName, + activity.id, + ); + + let hasChanges = false; + const enrichedBlocknote = []; + + for (const block of blocknote) { + const props = (block.props as Record) || {}; + const url = props.url as string | undefined; + + if ( + !isDefined(url) || + isDefined(extractFileIdFromUrl(url)) || + !url.includes('/files/attachment/') + ) { + enrichedBlocknote.push(block); + continue; + } + + let fileId = this.findMatchingFileId(url, urlToFileIdMap); + + if (!isDefined(fileId) && !isDryRun) { + const createdFileId = await this.createAttachmentFromUrl( + url, + block, + attachmentRepository, + fileRepository, + targetColumnName, + activity.id, + workspaceId, + twentyStandardApplication, + fileFieldMetadata, + ); + + if (isDefined(createdFileId)) { + fileId = createdFileId; + } + } + + if (isDefined(fileId)) { + this.logger.log( + `Enriching ${block.type} block in ${activityType} ${activity.id} with fileId: ${fileId}`, + ); + hasChanges = true; + + const signedUrl = this.filesFieldService.signFileUrl({ + fileId, + workspaceId, + }); + + enrichedBlocknote.push({ + ...block, + props: { + ...props, + url: signedUrl, + }, + }); + } else { + enrichedBlocknote.push(block); + } + } + + if (hasChanges && !isDryRun) { + const updatedBodyV2 = { + ...bodyV2, + blocknote: JSON.stringify(enrichedBlocknote), + }; + + await activityRepository.update( + { id: activity.id }, + { bodyV2: updatedBodyV2 }, + ); + } + } + } + + private async buildUrlToFileIdMap( + attachmentRepository: Awaited< + ReturnType + >, + targetColumnName: 'targetNoteId' | 'targetTaskId', + activityId: string, + ): Promise> { + const urlToFileIdMap = new Map(); + + const attachments = await attachmentRepository.find({ + where: { + [targetColumnName]: activityId, + }, + select: ['fullPath', 'file'], + }); + + for (const attachment of attachments) { + if ( + isDefined(attachment.file) && + Array.isArray(attachment.file) && + attachment.file.length > 0 + ) { + const fileData = attachment.file[0]; + + if (isDefined(attachment.fullPath) && isDefined(fileData.fileId)) { + urlToFileIdMap.set(attachment.fullPath, fileData.fileId); + } + } + } + + return urlToFileIdMap; + } + + private findMatchingFileId( + url: string, + urlToFileIdMap: Map, + ): string | undefined { + const normalizedUrl = url.includes(FileFolder.FilesField) + ? this.getAttachmentNewdRelativePath(url) + : this.getAttachmentLegacyRelativePath(url); + + return urlToFileIdMap.get(normalizedUrl); + } + + private async createAttachmentFromUrl( + url: string, + block: RichTextBlock, + attachmentRepository: Awaited< + ReturnType + >, + fileRepository: Repository, + targetColumnName: 'targetNoteId' | 'targetTaskId', + activityId: string, + workspaceId: string, + twentyStandardApplication: { id: string; universalIdentifier: string }, + fileFieldMetadata: { universalIdentifier: string }, + ): Promise { + try { + const props = block.props as Record; + const blockName = (props.name as string) || ''; + + const filePath = this.getAttachmentLegacyRelativePath(url); + + const { + type: fileExtension, + filename, + folderPath, + } = extractFolderPathFilenameAndTypeOrThrow(filePath); + + const fileId = v4(); + const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`; + const newResourcePath = `${FileFolder.FilesField}/${fileFieldMetadata.universalIdentifier}/${newFilename}`; + + await this.fileStorageService.copyLegacy({ + from: { + folderPath: `workspace-${workspaceId}/${folderPath}`, + filename: filename, + }, + to: { + folderPath: `${workspaceId}/${twentyStandardApplication.universalIdentifier}`, + filename: newResourcePath, + }, + }); + + const fileEntity = fileRepository.create({ + id: fileId, + path: newResourcePath, + workspaceId, + applicationId: twentyStandardApplication.id, + size: -1, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + + await fileRepository.save(fileEntity); + + const attachmentName = isNonEmptyString(blockName) ? blockName : filename; + + await attachmentRepository.save({ + [targetColumnName]: activityId, + name: attachmentName, + file: [ + { + fileId: fileEntity.id, + label: attachmentName, + }, + ], + }); + + this.logger.log( + `Created attachment for ${block.type} block with URL: ${url}`, + ); + + return fileId; + } catch (error) { + this.logger.error( + `Failed to create attachment from URL ${url}: ${error.message}`, + ); + + return undefined; + } + } + + getAttachmentLegacyRelativePath = (url: string): string => { + if (!url.includes('/files/attachment/')) { + throw new Error(`Invalid attachment legacy relative path: ${url}`); + } + + //https://example.com/files/attachment/token/filename.jpg -> attachment/filename.jpg + const attachmentPath = url.split('/files/attachment/')[1].split('/')[1]; + + return `${FileFolder.Attachment}/${attachmentPath}`; + }; + + getAttachmentNewdRelativePath = (url: string): string => { + if (!url.includes('/files-field/')) { + throw new Error(`Invalid new attachment path: ${url}`); + } + + //https://example.com/files-field/123/456/789/filename.jpg -> files-field/123/456/789/filename.jpg + const attachmentPath = url.split('/files-field/')[1]; + + return `${FileFolder.FilesField}/${attachmentPath}`; + }; +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts index 6fe5c7eec3b..754d4df4e18 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts @@ -176,10 +176,15 @@ export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMi ); throw error; } + + this.logger.log( + `Created file field metadata for attachments in workspace ${workspaceId}`, + ); + } else { + this.logger.log( + `[DRY RUN] Would create file field metadata for attachments in workspace ${workspaceId}`, + ); } - this.logger.log( - `Created file field metadata for attachments in workspace ${workspaceId}`, - ); } if (!isDefined(attachmentFileflatFieldMetadata)) { @@ -284,13 +289,6 @@ export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMi } } - if (!isDryRun) { - await this.featureFlagService.enableFeatureFlags( - [FeatureFlagKey.IS_FILES_FIELD_MIGRATED], - workspaceId, - ); - } - this.logger.log( `${isDryRun ? '[DRY RUN] ' : ''}Completed attachment files migration for workspace ${workspaceId}`, ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts index 3a6da077652..e811ba37deb 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts @@ -186,10 +186,15 @@ export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspaces ); throw error; } + + this.logger.log( + `Created avatarFile field metadata for workspace ${workspaceId}`, + ); + } else { + this.logger.log( + `[DRY RUN] Would create avatarFile field metadata for workspace ${workspaceId}`, + ); } - this.logger.log( - `Created avatarFile field metadata for workspace ${workspaceId}`, - ); } if (!isDefined(avatarFileFieldMetadata)) { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts index 03af16b46d1..382a53160b5 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BackfillFileSizeAndMimeTypeCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-file-size-and-mime-type.command'; +import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command'; import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command'; import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; @@ -9,6 +10,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; @@ -31,16 +33,19 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso WorkspaceCacheModule, FieldMetadataModule, ApplicationModule, + FilesFieldModule, ], providers: [ MigratePersonAvatarFilesCommand, MigrateAttachmentFilesCommand, BackfillFileSizeAndMimeTypeCommand, + MigrateActivityRichTextAttachmentFileIdsCommand, ], exports: [ MigratePersonAvatarFilesCommand, MigrateAttachmentFilesCommand, BackfillFileSizeAndMimeTypeCommand, + MigrateActivityRichTextAttachmentFileIdsCommand, ], }) export class V1_18_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index cc14cf96583..1c99e875d8c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -20,6 +20,8 @@ import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/ import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command'; import { MigrateWorkflowCodeStepsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-workflow-code-steps.command'; import { BackfillFileSizeAndMimeTypeCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-file-size-and-mime-type.command'; +import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command'; +import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command'; import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -55,6 +57,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.18 Commands protected readonly migratePersonAvatarFilesCommand: MigratePersonAvatarFilesCommand, protected readonly backfillFileSizeAndMimeTypeCommand: BackfillFileSizeAndMimeTypeCommand, + protected readonly migrateAttachmentFilesCommand: MigrateAttachmentFilesCommand, + protected readonly migrateActivityRichTextAttachmentFileIdsCommand: MigrateActivityRichTextAttachmentFileIdsCommand, ) { super( workspaceRepository, @@ -82,6 +86,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { const commands_1180: VersionCommands = [ this.migratePersonAvatarFilesCommand, + this.migrateAttachmentFilesCommand, + this.migrateActivityRichTextAttachmentFileIdsCommand, this.backfillFileSizeAndMimeTypeCommand, ]; diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts index 7754ac6635f..458b2e14380 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts @@ -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, + ), ], ]); } diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts index dbd067804f4..6e01e407b4b 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts index dc177e9917e..7568efdfff2 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts @@ -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; -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}`, + }, + }; + }); + }; } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts new file mode 100644 index 00000000000..b176ac97699 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts index ce5de84f63c..3267dea2a1b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts @@ -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 { + }): Promise { 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, - }, }, }); diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts index f7b5909473a..c1f6bd7e873 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -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 { + ): Promise { const stream = createReadStream(); const buffer = await streamToBuffer(stream); diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts new file mode 100644 index 00000000000..d542906fa03 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts @@ -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; +};