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
@@ -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,
},
});
};
@@ -139,10 +139,9 @@ export const ActivityRichTextEditor = ({
if (!canCreateActivity) {
setCanCreateActivity(true);
}
persistBodyDebounced(prepareBodyWithSignedUrls(activityBody));
},
[persistBodyDebounced, setCanCreateActivity, canCreateActivity],
[canCreateActivity, persistBodyDebounced, setCanCreateActivity],
);
const handleBodyChange = useRecoilCallback(
@@ -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,
});
@@ -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 };
@@ -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']);
});
});
@@ -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' }]);
});
});
@@ -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']);
});
@@ -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',
@@ -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');
});
});
@@ -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;
}
};
@@ -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);
@@ -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<Attachment>[], 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 });
@@ -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);
@@ -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);
@@ -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];
}
@@ -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;
};
@@ -7,6 +7,7 @@ export const UPLOAD_FILES_FIELD_FILE = gql`
path
size
createdAt
url
}
}
`;
@@ -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) {