File storage cleaning (#18381)

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

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2026-03-04 23:46:03 +01:00
committed by GitHub
parent c41a8e2b23
commit 26f0a416a1
117 changed files with 859 additions and 3239 deletions
File diff suppressed because one or more lines are too long
@@ -10,22 +10,23 @@ import { downloadFile } from '@/activities/files/utils/downloadFile';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { ModalContent, ModalHeader } from 'twenty-ui/layout';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ModalContent, ModalHeader } from 'twenty-ui/layout';
import { ActivityList } from '@/activities/components/ActivityList';
import {
type AttachmentWithFile,
filterAttachmentsWithFile,
} from '@/activities/files/utils/filterAttachmentsWithFile';
import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { IconDownload, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
PermissionFlagType,
FeatureFlagKey,
} from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { AttachmentRow } from './AttachmentRow';
const DocumentViewer = lazy(() =>
@@ -122,16 +123,12 @@ export const AttachmentList = ({
const { uploadAttachmentFile } = useUploadAttachmentFile();
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [previewedAttachment, setPreviewedAttachment] =
useState<Attachment | null>(null);
useState<AttachmentWithFile | null>(null);
const isAttachmentPreviewEnabled = useAtomStateValue(
isAttachmentPreviewEnabledState,
);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const hasDownloadPermission = useHasPermissionFlag(
PermissionFlagType.DOWNLOAD_FILE,
);
@@ -142,15 +139,7 @@ export const AttachmentList = ({
const { openModal, closeModal } = useModal();
const getAttachmentUrl = (attachment: Attachment) => {
const fileUrl = isFilesFieldMigrated
? attachment.file?.[0]?.url || attachment.fullPath
: attachment.fullPath;
assertIsDefinedOrThrow(fileUrl, new Error(t`File URL is not defined`));
return fileUrl;
};
const attachmentsWithFile = filterAttachmentsWithFile(attachments);
const onUploadFile = async (file: File) => {
await uploadAttachmentFile(file, targetableObject);
@@ -162,7 +151,7 @@ export const AttachmentList = ({
}
};
const handlePreview = (attachment: Attachment) => {
const handlePreview = (attachment: AttachmentWithFile) => {
if (!isAttachmentPreviewEnabled) return;
setPreviewedAttachment(attachment);
openModal(PREVIEW_MODAL_ID);
@@ -174,20 +163,18 @@ export const AttachmentList = ({
};
const handleDownload = () => {
if (!previewedAttachment) return;
downloadFile(
getAttachmentUrl(previewedAttachment),
previewedAttachment.name,
);
if (!isDefined(previewedAttachment)) return;
const attachmentUrl = getAttachmentUrl({ attachment: previewedAttachment });
downloadFile(attachmentUrl, previewedAttachment.name);
};
return (
<>
{attachments && attachments.length > 0 && (
{attachmentsWithFile && attachmentsWithFile.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{attachments.length}</StyledCount>
{title} <StyledCount>{attachmentsWithFile.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
@@ -201,7 +188,7 @@ export const AttachmentList = ({
/>
) : (
<ActivityList>
{attachments.map((attachment) => (
{attachmentsWithFile.map((attachment) => (
<AttachmentRow
key={attachment.id}
attachment={attachment}
@@ -261,7 +248,9 @@ export const AttachmentList = ({
>
<DocumentViewer
documentName={previewedAttachment.name}
documentUrl={getAttachmentUrl(previewedAttachment)}
documentUrl={getAttachmentUrl({
attachment: previewedAttachment,
})}
/>
</Suspense>
</ModalContent>
@@ -1,8 +1,6 @@
import { ActivityRow } from '@/activities/components/ActivityRow';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { type Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
@@ -11,21 +9,19 @@ import {
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { getFileCategoryFromExtension } from '@/object-record/record-field/ui/utils/getFileCategoryFromExtension';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useState } from 'react';
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type AttachmentWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
import { FileIcon } from '@/file/components/FileIcon';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isNavigationModifierPressed } from 'twenty-ui/utilities';
import {
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
@@ -77,8 +73,8 @@ const StyledLinkContainer = styled.div`
`;
type AttachmentRowProps = {
attachment: Attachment;
onPreview?: (attachment: Attachment) => void;
attachment: AttachmentWithFile;
onPreview?: (attachment: AttachmentWithFile) => void;
};
export const AttachmentRow = ({
@@ -88,31 +84,19 @@ export const AttachmentRow = ({
const { theme } = useContext(ThemeContext);
const [isEditing, setIsEditing] = useState(false);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const hasDownloadPermission = useHasPermissionFlag(
PermissionFlagType.DOWNLOAD_FILE,
);
const { name: originalFileName, extension: attachmentFileExtension } =
getFileNameAndExtension(
isFilesFieldMigrated
? (attachment.file?.[0]?.label as string)
: attachment.name,
);
getFileNameAndExtension(attachment.file.label);
const [attachmentFileName, setAttachmentFileName] =
useState(originalFileName);
const fileCategory = isFilesFieldMigrated
? getFileCategoryFromExtension(attachment.file?.[0]?.extension)
: attachment.fileCategory;
const fileCategory = getFileCategoryFromExtension(attachment.file.extension);
const fileUrl = isFilesFieldMigrated
? (attachment.file?.[0]?.url as string) // TODO : fix attachment.file type after Files field migration
: attachment.fullPath;
const fileUrl = attachment.file.url;
const { destroyOneRecord: destroyOneAttachment } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
@@ -138,16 +122,12 @@ export const AttachmentRow = ({
idToUpdate: attachment.id,
updateOneRecordInput: {
name: newFileName,
...(isFilesFieldMigrated && isDefined(attachment.file?.[0]?.fileId)
? {
file: [
{
fileId: attachment.file?.[0]?.fileId,
label: newFileName,
},
],
}
: {}),
file: [
{
fileId: attachment.file.fileId,
label: newFileName,
},
],
},
});
};
@@ -1,5 +1,4 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { getFileType } from '@/activities/files/utils/getFileType';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@@ -12,23 +11,17 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
FieldMetadataType,
FileFolder,
useUploadFileMutation,
useUploadFilesFieldFileMutation,
} from '~/generated-metadata/graphql';
export const useUploadAttachmentFile = () => {
const apolloClient = useApolloClient();
const [uploadFile] = useUploadFileMutation({ client: apolloClient });
const [uploadFilesFieldFile] = useUploadFilesFieldFileMutation({
client: apolloClient,
});
const isAttachmentMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { objectMetadataItem: attachmentMetadata } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Attachment,
@@ -48,44 +41,19 @@ export const useUploadAttachmentFile = () => {
file: File,
targetableObject: ActivityTargetableObject,
) => {
let attachmentPath: string;
let fileId: string | undefined;
let fileUrl: string | undefined;
assertIsDefinedOrThrow(
filesFieldMetadataId,
new Error(t`File field not found for attachment object`),
);
if (isFilesFieldMigrated) {
assertIsDefinedOrThrow(
filesFieldMetadataId,
new Error(t`File field not found for attachment object`),
);
const result = await uploadFilesFieldFile({
variables: { file, fieldMetadataId: filesFieldMetadataId },
});
const result = await uploadFilesFieldFile({
variables: { file, fieldMetadataId: filesFieldMetadataId },
});
const uploadedFile = result?.data?.uploadFilesFieldFile;
const uploadedFile = result?.data?.uploadFilesFieldFile;
if (!isDefined(uploadedFile)) {
throw new Error("Couldn't upload the attachment.");
}
attachmentPath = uploadedFile.path;
fileId = uploadedFile.id;
fileUrl = uploadedFile.url;
} else {
const result = await uploadFile({
variables: {
file,
fileFolder: FileFolder.Attachment,
},
});
const signedFile = result?.data?.uploadFile;
if (!isDefined(signedFile)) {
throw new Error("Couldn't upload the attachment.");
}
attachmentPath = signedFile.path;
if (!isDefined(uploadedFile)) {
throw new Error("Couldn't upload the attachment.");
}
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
@@ -95,28 +63,20 @@ export const useUploadAttachmentFile = () => {
const attachmentToCreate = {
name: file.name,
fullPath: isFilesFieldMigrated ? null : attachmentPath,
fileCategory: getFileType(file.name),
[targetableObjectFieldIdName]: targetableObject.id,
...(isFilesFieldMigrated && isDefined(fileId)
? {
file: [
{
fileId,
label: file.name,
},
],
}
: {}),
file: [
{
fileId: uploadedFile.id,
label: file.name,
},
],
} as Partial<Attachment>;
const createdAttachment = await createOneAttachment(attachmentToCreate);
await createOneAttachment(attachmentToCreate);
return {
attachmentAbsoluteURL: isFilesFieldMigrated
? fileUrl
: createdAttachment.fullPath,
attachmentFileId: fileId,
attachmentAbsoluteURL: uploadedFile.url,
attachmentFileId: uploadedFile.id,
};
};
@@ -7,9 +7,11 @@ export type { AttachmentFileCategory };
export type Attachment = {
id: string;
name: string;
/** @deprecated Use `file[0].url` field instead */
fullPath: string;
/** @deprecated Use `file[0].extension` field instead */
fileCategory: AttachmentFileCategory;
file?: FieldFilesValue[] | null;
file: FieldFilesValue[] | null;
companyId?: string | null;
personId?: string | null;
taskId?: string | null;
@@ -0,0 +1,34 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
export type FieldFilesValueWithUrl = FieldFilesValue & {
url: string;
};
type AttachmentWithFiles = Attachment & {
file: [FieldFilesValueWithUrl, ...FieldFilesValueWithUrl[]];
};
export type AttachmentWithFile = Omit<Attachment, 'file'> & {
file: FieldFilesValueWithUrl;
};
const hasFileWithUrl = (
attachment: Attachment,
): attachment is AttachmentWithFiles => {
return isNonEmptyArray(attachment.file) && isDefined(attachment.file[0].url);
};
const normalizeAttachment = (
attachment: AttachmentWithFiles,
): AttachmentWithFile => ({
...attachment,
file: attachment.file[0],
});
export const filterAttachmentsWithFile = (
attachments: Attachment[],
): AttachmentWithFile[] => {
return attachments.filter(hasFileWithUrl).map(normalizeAttachment);
};
@@ -6,13 +6,12 @@ describe('filterAttachmentsToRestore', () => {
const softDeletedAttachments = [
{
id: '1',
fullPath: 'https://exemple.com/test.txt',
file: [{ url: 'https://exemple.com/test.txt' }],
},
] as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore({
attachmentPathsToRestore: [],
softDeletedAttachments,
isFilesFieldMigrated: false,
});
expect(attachmentIdsToRestore).toEqual([]);
});
@@ -23,7 +22,6 @@ describe('filterAttachmentsToRestore', () => {
'https://exemple.com/files/attachment/test.txt',
],
softDeletedAttachments: [],
isFilesFieldMigrated: false,
});
expect(attachmentIdsToRestore).toEqual([]);
});
@@ -32,17 +30,16 @@ describe('filterAttachmentsToRestore', () => {
const softDeletedAttachments = [
{
id: '1',
fullPath: 'https://exemple.com/files/images/test.txt',
file: [{ url: 'https://exemple.com/files/images/test.txt' }],
},
{
id: '2',
fullPath: 'https://exemple.com/files/images/test2.txt',
file: [{ url: 'https://exemple.com/files/images/test2.txt' }],
},
] as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore({
attachmentPathsToRestore: ['https://exemple.com/files/images/test.txt'],
softDeletedAttachments,
isFilesFieldMigrated: false,
});
expect(attachmentIdsToRestore).toEqual(['1']);
});
@@ -6,12 +6,12 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => {
const attachments = [
{
id: '1',
fullPath: 'https://exemple.com/files/images/test.txt',
file: [{ url: 'https://exemple.com/files/images/test.txt' }],
name: 'image',
},
{
id: '2',
fullPath: 'https://exemple.com/files/images/test2.txt',
file: [{ url: 'https://exemple.com/files/images/test2.txt' }],
name: 'image1',
},
] as Attachment[];
@@ -33,7 +33,7 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => {
},
]);
const attachmentIdsAndNameToUpdate =
getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments, false);
getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments);
expect(attachmentIdsAndNameToUpdate).toEqual([]);
});
@@ -41,12 +41,12 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => {
const attachments = [
{
id: '1',
fullPath: 'https://exemple.com/files/images/test.txt',
file: [{ url: 'https://exemple.com/files/images/test.txt' }],
name: 'image',
},
{
id: '2',
fullPath: 'https://exemple.com/files/images/test2.txt',
file: [{ url: 'https://exemple.com/files/images/test2.txt' }],
name: 'image1',
},
] as Attachment[];
@@ -68,7 +68,7 @@ describe('getActivityAttachmentIdsAndNameToUpdate', () => {
},
]);
const attachmentIdsAndNameToUpdate =
getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments, false);
getActivityAttachmentIdsAndNameToUpdate(activityBody, attachments);
expect(attachmentIdsAndNameToUpdate).toEqual([{ id: '2', name: 'image4' }]);
});
});
@@ -6,11 +6,11 @@ describe('getActivityAttachmentIdsToDelete', () => {
const attachments = [
{
id: '1',
fullPath: 'https://example.com/files/images/test.txt',
file: [{ url: 'https://example.com/files/images/test.txt' }],
},
{
id: '2',
fullPath: 'https://example.com/files/images/test2.txt',
file: [{ url: 'https://example.com/files/images/test2.txt' }],
},
] as Attachment[];
const newActivityBody = JSON.stringify([
@@ -37,7 +37,6 @@ describe('getActivityAttachmentIdsToDelete', () => {
newActivityBody,
attachments,
oldActivityBody,
false,
);
expect(attachmentIdsToDelete).toEqual([]);
});
@@ -46,11 +45,11 @@ describe('getActivityAttachmentIdsToDelete', () => {
const attachments = [
{
id: '1',
fullPath: 'https://example.com/files/images/test.txt',
file: [{ url: 'https://example.com/files/images/test.txt' }],
},
{
id: '2',
fullPath: 'https://example.com/files/images/test2.txt',
file: [{ url: 'https://example.com/files/images/test2.txt' }],
},
] as Attachment[];
const newActivityBody = JSON.stringify([
@@ -73,7 +72,6 @@ describe('getActivityAttachmentIdsToDelete', () => {
newActivityBody,
attachments,
oldActivityBody,
false,
);
expect(attachmentIdsToDelete).toEqual(['2']);
});
@@ -17,7 +17,6 @@ describe('getActivityAttachmentPathsToRestore', () => {
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newActivityBody,
oldActivityAttachments,
false,
);
expect(attachmentPathsToRestore).toEqual([]);
});
@@ -37,14 +36,13 @@ describe('getActivityAttachmentPathsToRestore', () => {
const oldActivityAttachments = [
{
id: '1',
fullPath: 'https://example.com/files/images/test.txt',
file: [{ url: 'https://example.com/files/images/test.txt' }],
},
] as Attachment[];
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newActivityBody,
oldActivityAttachments,
false,
);
expect(attachmentPathsToRestore).toEqual([
'https://example.com/files/images/test2.txt',
@@ -1,9 +1,14 @@
import { getAttachmentPath } from '@/activities/utils/getAttachmentPath';
import { isDefined } from 'twenty-shared/utils';
export const compareUrls = (
firstAttachmentUrl: string,
secondAttachmentUrl: string,
firstAttachmentUrl: string | undefined,
secondAttachmentUrl: string | undefined,
): boolean => {
if (!isDefined(firstAttachmentUrl) || !isDefined(secondAttachmentUrl)) {
return false;
}
try {
const urlA = new URL(firstAttachmentUrl);
const urlB = new URL(secondAttachmentUrl);
@@ -1,23 +1,19 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
import { compareUrls } from '@/activities/utils/compareUrls';
import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl';
export const filterAttachmentsToRestore = ({
attachmentPathsToRestore,
softDeletedAttachments,
isFilesFieldMigrated,
}: {
attachmentPathsToRestore: string[];
softDeletedAttachments: Attachment[];
isFilesFieldMigrated: boolean;
}) => {
return softDeletedAttachments
return filterAttachmentsWithFile(softDeletedAttachments)
.filter((attachment) =>
attachmentPathsToRestore.some((path) =>
compareUrls(
getAttachmentUrl({ attachment, isFilesFieldMigrated }),
path,
),
compareUrls(getAttachmentUrl({ attachment }), path),
),
)
.map((attachment) => attachment.id);
@@ -1,4 +1,5 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
import { compareUrls } from '@/activities/utils/compareUrls';
import {
type AttachmentInfo,
@@ -10,19 +11,17 @@ import { isDefined } from 'twenty-shared/utils';
export const getActivityAttachmentIdsAndNameToUpdate = (
newActivityBody: string,
oldActivityAttachments: Attachment[] = [],
isFilesFieldMigrated: boolean,
) => {
const activityAttachmentsNameAndPaths =
getActivityAttachmentPathsAndName(newActivityBody);
if (activityAttachmentsNameAndPaths.length === 0) return [];
const attachmentsWithFile = filterAttachmentsWithFile(oldActivityAttachments);
return activityAttachmentsNameAndPaths.reduce(
(acc: Partial<Attachment>[], activity: AttachmentInfo) => {
const foundActivity = oldActivityAttachments.find((attachment) =>
compareUrls(
getAttachmentUrl({ attachment, isFilesFieldMigrated }),
activity.path,
),
const foundActivity = attachmentsWithFile.find((attachment) =>
compareUrls(getAttachmentUrl({ attachment }), activity.path),
);
if (isDefined(foundActivity) && foundActivity.name !== activity.name) {
acc.push({ id: foundActivity.id, name: activity.name });
@@ -1,4 +1,5 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
import { compareUrls } from '@/activities/utils/compareUrls';
import { getActivityAttachmentPathsAndName } from '@/activities/utils/getActivityAttachmentPathsAndName';
import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl';
@@ -7,7 +8,6 @@ export const getActivityAttachmentIdsToDelete = (
newActivityBody: string,
oldActivityAttachments: Attachment[] = [],
oldActivityBody: string,
isFilesFieldMigrated: boolean,
) => {
if (oldActivityAttachments.length === 0) return [];
@@ -26,13 +26,10 @@ export const getActivityAttachmentIdsToDelete = (
)
.map((activity) => activity.path);
return oldActivityAttachments
return filterAttachmentsWithFile(oldActivityAttachments)
.filter((attachment) =>
pathsToDelete.some((pathToDelete) =>
compareUrls(
getAttachmentUrl({ attachment, isFilesFieldMigrated }),
pathToDelete,
),
compareUrls(getAttachmentUrl({ attachment }), pathToDelete),
),
)
.map((attachment) => attachment.id);
@@ -1,4 +1,5 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
import { compareUrls } from '@/activities/utils/compareUrls';
import { getActivityAttachmentPathsAndName } from '@/activities/utils/getActivityAttachmentPathsAndName';
import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl';
@@ -6,19 +7,17 @@ import { getAttachmentUrl } from '@/activities/utils/getAttachmentUrl';
export const getActivityAttachmentPathsToRestore = (
newActivityBody: string,
oldActivityAttachments: Attachment[],
isFilesFieldMigrated: boolean,
) => {
const newActivityAttachmentPaths =
getActivityAttachmentPathsAndName(newActivityBody);
const attachmentsWithFile = filterAttachmentsWithFile(oldActivityAttachments);
const pathsToRestore = newActivityAttachmentPaths
.filter(
(newActivity) =>
!oldActivityAttachments.some((attachment) =>
compareUrls(
newActivity.path,
getAttachmentUrl({ attachment, isFilesFieldMigrated }),
),
!attachmentsWithFile.some((attachment) =>
compareUrls(newActivity.path, getAttachmentUrl({ attachment })),
),
)
.map((activity) => activity.path);
@@ -1,16 +1,9 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { type AttachmentWithFile } from '@/activities/files/utils/filterAttachmentsWithFile';
export const getAttachmentUrl = ({
attachment,
isFilesFieldMigrated,
}: {
attachment: Attachment;
isFilesFieldMigrated: boolean;
attachment: AttachmentWithFile;
}): 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;
return attachment.file.url;
};
@@ -1,28 +1,17 @@
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/MaxAttachmentSize';
import { formatFileSize } from '@/file/utils/formatFileSize';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useApolloClient } from '@apollo/client';
import { t } from '@lingui/core/macro';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import {
FeatureFlagKey,
useCreateFileMutation,
useUploadWorkflowFileMutation,
} from '~/generated-metadata/graphql';
import { useUploadWorkflowFileMutation } from '~/generated-metadata/graphql';
import { logError } from '~/utils/logError';
export const useUploadWorkflowFile = () => {
const isOtherFileMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
);
const [uploadWorkflowFileMutation] = useUploadWorkflowFileMutation();
const apolloClient = useApolloClient();
const [createFile] = useCreateFileMutation({ client: apolloClient });
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const uploadWorkflowFile = async (
@@ -38,41 +27,20 @@ export const useUploadWorkflowFile = () => {
return null;
}
let workflowFile: WorkflowAttachment;
if (isOtherFileMigrated) {
const result = await uploadWorkflowFileMutation({
variables: { file },
});
const uploadedFile = result?.data?.uploadWorkflowFile;
if (!isDefined(uploadedFile)) {
throw new Error('File upload failed');
}
workflowFile = {
id: uploadedFile.id,
name: file.name,
size: uploadedFile.size,
type: extractFolderPathFilenameAndTypeOrThrow(uploadedFile.path).type,
createdAt: uploadedFile.createdAt,
};
} else {
const result = await createFile({
variables: { file },
});
const uploadedFile = result?.data?.createFile;
if (!isDefined(uploadedFile)) {
throw new Error('File upload failed');
}
workflowFile = {
id: uploadedFile.id,
name: file.name,
size: uploadedFile.size,
type: extractFolderPathFilenameAndTypeOrThrow(uploadedFile.path).type,
createdAt: uploadedFile.createdAt,
};
const result = await uploadWorkflowFileMutation({
variables: { file },
});
const uploadedFile = result?.data?.uploadWorkflowFile;
if (!isDefined(uploadedFile)) {
throw new Error('File upload failed');
}
const workflowFile: WorkflowAttachment = {
id: uploadedFile.id,
name: file.name,
size: uploadedFile.size,
type: extractFolderPathFilenameAndTypeOrThrow(uploadedFile.path).type,
createdAt: uploadedFile.createdAt,
};
const fileName = file.name;
enqueueSuccessSnackBar({
@@ -7,7 +7,7 @@ import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantM
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isExtendedFileUIPart, type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
@@ -151,7 +151,7 @@ export const AIChatMessage = ({
const showError =
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
const fileParts = message.parts.filter((part) => part.type === 'file');
const fileParts = message.parts.filter(isExtendedFileUIPart);
return (
<StyledMessageBubble key={message.id} isUser={isUser}>
@@ -176,6 +176,7 @@ type CodeExecutionDisplayProps = {
stderr: string;
exitCode?: number;
files?: Array<{
fileId: string;
filename: string;
url: string;
mimeType?: string;
@@ -337,7 +338,7 @@ export const CodeExecutionDisplay = ({
const filename = file.filename;
return (
<StyledFileCard key={file.url}>
<StyledFileCard key={file.fileId}>
<StyledFilePreview>
{isPreviewableMimeType(file.mimeType) ? (
<StyledPreviewImage
@@ -160,7 +160,12 @@ export const ToolStepRenderer = ({
stdout?: string;
stderr?: string;
exitCode?: number;
files?: Array<{ filename: string; url: string; mimeType?: string }>;
files?: Array<{
fileId: string;
filename: string;
url: string;
mimeType?: string;
}>;
};
} | null;
@@ -66,6 +66,7 @@ print("Chart saved successfully!")`,
executionTimeMs: 2340,
files: [
{
fileId: '550e8400-e29b-41d4-a716-446655440005',
filename: 'sales_chart.png',
url: 'https://picsum.photos/800/480',
mimeType: 'image/png',
@@ -103,11 +103,13 @@ export const WithImageFiles: Story = {
isRunning: false,
files: [
{
fileId: '550e8400-e29b-41d4-a716-446655440001',
filename: 'revenue_chart.png',
url: 'https://picsum.photos/800/480',
mimeType: 'image/png',
},
{
fileId: '550e8400-e29b-41d4-a716-446655440002',
filename: 'pie_chart.png',
url: 'https://picsum.photos/600/400',
mimeType: 'image/png',
@@ -142,11 +144,13 @@ print("Files exported successfully!")`,
isRunning: false,
files: [
{
fileId: '550e8400-e29b-41d4-a716-446655440003',
filename: 'report.csv',
url: 'data:text/csv,name%2Csales%0AAlice%2C1200%0ABob%2C1500',
mimeType: 'text/csv',
},
{
fileId: '550e8400-e29b-41d4-a716-446655440004',
filename: 'data.json',
url: 'data:application/json,%7B%22name%22%3A%5B%22Alice%22%5D%7D',
mimeType: 'application/json',
@@ -2,43 +2,66 @@ import { type AttachmentFileCategory } from '@/activities/files/types/Attachment
import { getFileType } from '@/activities/files/utils/getFileType';
import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
import { IconMapping } from '@/file/utils/fileIconMappings';
import { getFileCategoryFromExtension } from '@/object-record/record-field/ui/utils/getFileCategoryFromExtension';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type FileUIPart } from 'ai';
import { useCallback, useContext } from 'react';
import { type ExtendedFileUIPart } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import {
AvatarOrIcon,
Chip,
ChipVariant,
LinkChip,
} from 'twenty-ui/components';
import { AvatarOrIcon, Chip, ChipVariant } from 'twenty-ui/components';
import { type IconComponent, IconX } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { ThemeContext } from 'twenty-ui/theme';
const StyledClickableContainer = styled.div<{ clickable: boolean }>`
cursor: ${({ clickable }: { clickable: boolean }) =>
clickable ? 'pointer' : 'inherit'};
display: inline-flex;
min-width: 0;
`;
export const AgentChatFilePreview = ({
file,
onRemove,
isUploading,
}: {
file: FileUIPart | File;
file: ExtendedFileUIPart | File;
onRemove?: () => void;
isUploading?: boolean;
}) => {
const { theme } = useContext(ThemeContext);
const iconColors: Record<AttachmentFileCategory, string> =
useFileCategoryColors();
const setFilePreview = useSetAtomState(filePreviewState);
const fileName =
file instanceof File ? file.name : (file.filename ?? t`Unknown file`);
const fileUrl = file instanceof File ? undefined : file.url;
const fileId = file instanceof File ? undefined : file.fileId;
const fileCategory: AttachmentFileCategory = getFileType(fileName);
const extension = fileName.split('.').pop() ?? '';
const FileCategoryIcon: IconComponent = IconMapping[fileCategory];
const iconBackgroundColor: string = iconColors[fileCategory];
const handleClick = useCallback(() => {
if (!isDefined(fileUrl) || !isDefined(fileId)) {
return;
}
setFilePreview({
fileId,
label: fileName,
extension,
url: fileUrl,
fileCategory: getFileCategoryFromExtension(extension),
});
}, [fileUrl, fileId, fileName, extension, setFilePreview]);
const leftComponent = isUploading ? (
<Loader color="yellow" />
) : (
@@ -57,31 +80,22 @@ export const AgentChatFilePreview = ({
) : undefined;
const hasRightDivider = isDefined(onRemove);
const isClickable = isDefined(fileUrl) && isDefined(fileId);
if (isDefined(fileUrl)) {
return (
<LinkChip
return (
<StyledClickableContainer
clickable={isClickable}
onClick={isClickable ? handleClick : undefined}
>
<Chip
label={fileName}
emptyLabel={t`Untitled`}
variant={ChipVariant.Static}
to={fileUrl}
target="_blank"
clickable={isClickable}
leftComponent={leftComponent}
rightComponent={rightComponent}
rightComponentDivider={hasRightDivider}
/>
);
}
return (
<Chip
label={fileName}
emptyLabel={t`Untitled`}
variant={ChipVariant.Static}
clickable={false}
leftComponent={leftComponent}
rightComponent={rightComponent}
rightComponentDivider={hasRightDivider}
/>
</StyledClickableContainer>
);
};
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const UPLOAD_AI_CHAT_FILE = gql`
mutation uploadAIChatFile($file: Upload!) {
uploadAIChatFile(file: $file) {
id
path
size
createdAt
url
}
}
`;
@@ -32,6 +32,7 @@ export const GET_CHAT_MESSAGES = gql`
fileMediaType
fileFilename
fileUrl
fileId
providerMetadata
createdAt
}
@@ -4,17 +4,16 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useApolloClient } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { type FileUIPart } from 'ai';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
FileFolder,
useUploadFileMutation,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { type AgentChatFileUIPart } from '@/ai/types/agent-chat-file-ui-part.type';
import { useUploadAiChatFileMutation } from '~/generated-metadata/graphql';
export const useAIChatFileUpload = () => {
const apolloClient = useApolloClient();
const [uploadFile] = useUploadFileMutation({ client: apolloClient });
const [uploadAiChatFile] = useUploadAiChatFileMutation({
client: apolloClient,
});
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useAtomState(
@@ -24,33 +23,28 @@ export const useAIChatFileUpload = () => {
agentChatUploadedFilesState,
);
const sendFile = async (file: File): Promise<FileUIPart | null> => {
const sendFile = async (file: File): Promise<AgentChatFileUIPart | null> => {
try {
const result = await uploadFile({
const result = await uploadAiChatFile({
variables: {
file,
fileFolder: FileFolder.AgentChat,
},
});
const response = result?.data?.uploadFile;
const response = result?.data?.uploadAIChatFile;
if (!isDefined(response)) {
throw new Error(t`Couldn't upload the file.`);
}
const signedPath = buildSignedPath({
path: response.path,
token: response.token,
});
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return {
filename: file.name,
mediaType: file.type,
url: `${REACT_APP_SERVER_BASE_URL}/files/${signedPath}`,
url: response.url,
fileId: response.id,
type: 'file',
};
} catch {
@@ -67,7 +61,7 @@ export const useAIChatFileUpload = () => {
files.map((file) => sendFile(file)),
);
const successfulUploads = uploadResults.reduce<FileUIPart[]>(
const successfulUploads = uploadResults.reduce<AgentChatFileUIPart[]>(
(acc, result) => {
if (result.status === 'fulfilled' && isDefined(result.value)) {
acc.push(result.value);
@@ -1,8 +1,10 @@
import { type FileUIPart } from 'ai';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatUploadedFilesState = createAtomState<FileUIPart[]>({
import { type AgentChatFileUIPart } from '@/ai/types/agent-chat-file-ui-part.type';
export const agentChatUploadedFilesState = createAtomState<
AgentChatFileUIPart[]
>({
key: 'ai/agentChatUploadedFilesState',
defaultValue: [],
});
@@ -0,0 +1,5 @@
import { type FileUIPart } from 'ai';
export type AgentChatFileUIPart = FileUIPart & {
fileId: string;
};
@@ -1,5 +1,8 @@
import { type ReasoningUIPart, type ToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import {
type ExtendedFileUIPart,
type ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import { type AgentMessagePart } from '~/generated-metadata/graphql';
export const mapDBPartToUIMessagePart = (
@@ -23,7 +26,8 @@ export const mapDBPartToUIMessagePart = (
mediaType: part.fileMediaType!,
filename: part.fileFilename!,
url: part.fileUrl!,
};
fileId: part.fileId!,
} as ExtendedFileUIPart;
case 'source-url':
return {
type: 'source-url',
@@ -1,10 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_FILE = gql`
mutation uploadFile($file: Upload!, $fileFolder: FileFolder) {
uploadFile(file: $file, fileFolder: $fileFolder) {
path
token
}
}
`;
@@ -1,10 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_IMAGE = gql`
mutation uploadImage($file: Upload!, $fileFolder: FileFolder) {
uploadImage(file: $file, fileFolder: $fileFolder) {
path
token
}
}
`;
@@ -8,13 +8,8 @@ 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,
});
@@ -47,7 +42,6 @@ export const useAttachmentSync = (attachments: Attachment[]) => {
newBody,
attachments,
previousBodyOrEmptyArray,
isFilesFieldMigrated,
);
if (attachmentIdsToDelete.length > 0) {
@@ -59,7 +53,6 @@ export const useAttachmentSync = (attachments: Attachment[]) => {
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newBody,
attachments,
isFilesFieldMigrated,
);
if (attachmentPathsToRestore.length > 0) {
@@ -69,7 +62,6 @@ export const useAttachmentSync = (attachments: Attachment[]) => {
const attachmentIdsToRestore = filterAttachmentsToRestore({
attachmentPathsToRestore,
softDeletedAttachments: softDeletedAttachments ?? [],
isFilesFieldMigrated,
});
await restoreAttachments({
@@ -80,7 +72,6 @@ export const useAttachmentSync = (attachments: Attachment[]) => {
const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate(
newBody,
attachments,
isFilesFieldMigrated,
);
for (const attachmentToUpdate of attachmentsToUpdate) {
@@ -12,16 +12,12 @@ import { recordStoreIdentifierFamilySelector } from '@/object-record/record-stor
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Avatar } from 'twenty-ui/display';
import {
FeatureFlagKey,
FieldMetadataType,
} from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
@@ -57,16 +53,11 @@ export const CommandMenuRecordInfo = ({
},
) as string | null;
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const recordIdentifier = useAtomFamilySelectorValue(
recordStoreIdentifierFamilySelector,
{
recordId: objectRecordId,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
},
);
@@ -1,12 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_FILE = gql`
mutation CreateFile($file: Upload!) {
createFile(file: $file) {
id
path
size
createdAt
}
}
`;
@@ -1,12 +0,0 @@
import { gql } from '@apollo/client';
export const DELETE_FILE = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
path
size
createdAt
}
}
`;
@@ -5,8 +5,6 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const PreComputedChipGeneratorsProvider = ({
children,
@@ -15,17 +13,13 @@ export const PreComputedChipGeneratorsProvider = ({
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } =
useMemo(() => {
return getRecordChipGenerators(
objectMetadataItems,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
);
}, [allowRequestsToTwentyIcons, isFilesFieldMigrated, objectMetadataItems]);
}, [allowRequestsToTwentyIcons, objectMetadataItems]);
return (
<>
@@ -3,13 +3,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isNonEmptyString } from '@sniptt/guards';
import {
getImageAbsoluteURI,
getLogoUrlFromDomainName,
isDefined,
} from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
export const getAvatarUrl = (
@@ -17,7 +11,6 @@ export const getAvatarUrl = (
record: ObjectRecord,
imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
allowRequestsToTwentyIcons?: boolean | undefined,
isFilesFieldMigrated?: boolean | undefined,
) => {
if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) {
return record.avatarUrl ?? undefined;
@@ -33,16 +26,7 @@ export const getAvatarUrl = (
}
if (objectNameSingular === CoreObjectNameSingular.Person) {
if (isFilesFieldMigrated === true) {
return record.avatarFile?.[0]?.url ?? '';
}
return isNonEmptyString(record.avatarUrl)
? getImageAbsoluteURI({
imageUrl: record.avatarUrl,
baseUrl: REACT_APP_SERVER_BASE_URL,
})
: '';
return record.avatarFile?.[0]?.url ?? '';
}
const imageIdentifierFieldValue = getImageIdentifierFieldValue(
@@ -7,12 +7,10 @@ export const getImageIdentifierFieldMetadataItem = (
ObjectMetadataItem,
'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
>,
isFilesFieldMigrated?: boolean,
): FieldMetadataItem | undefined =>
objectMetadataItem.fields.find((fieldMetadataItem) =>
isImageIdentifierField({
fieldMetadataItem,
objectMetadataItem,
isFilesFieldMigrated,
}),
);
@@ -12,7 +12,6 @@ export const getObjectRecordIdentifier = ({
objectMetadataItem,
record,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}: {
objectMetadataItem: Pick<
ObjectMetadataItem,
@@ -23,7 +22,6 @@ export const getObjectRecordIdentifier = ({
>;
record: ObjectRecord;
allowRequestsToTwentyIcons: boolean;
isFilesFieldMigrated?: boolean;
}): ObjectRecordIdentifier => {
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
@@ -45,7 +43,6 @@ export const getObjectRecordIdentifier = ({
record,
imageIdentifierFieldMetadata,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
);
const linkToShowPage = getLinkToShowPage(
@@ -5,14 +5,12 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
export const isImageIdentifierField = ({
fieldMetadataItem,
objectMetadataItem,
isFilesFieldMigrated,
}: {
fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'name'>;
objectMetadataItem: Pick<
ObjectMetadataItem,
'imageIdentifierFieldMetadataId' | 'nameSingular'
>;
isFilesFieldMigrated?: boolean;
}) => {
if (
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company &&
@@ -22,10 +20,7 @@ export const isImageIdentifierField = ({
}
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) {
if (isFilesFieldMigrated === true) {
return fieldMetadataItem.name === 'avatarFile';
}
return fieldMetadataItem.name === 'avatarUrl';
return fieldMetadataItem.name === 'avatarFile';
}
return (
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
@@ -45,7 +45,7 @@ exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record
"name": true,
},
"people": {
"avatarUrl": true,
"avatarFile": true,
"id": true,
"name": true,
},
@@ -53,7 +53,7 @@ exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record
"previousEmployees": {
"id": true,
"person": {
"avatarUrl": true,
"avatarFile": true,
"id": true,
"name": true,
},
@@ -12,14 +12,11 @@ export const buildIdentifierGqlFields = (
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
>,
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField = getImageIdentifierFieldMetadataItem(
objectMetadata,
isFilesFieldMigrated,
);
const imageIdentifierField =
getImageIdentifierFieldMetadataItem(objectMetadata);
return {
id: true,
@@ -30,7 +30,6 @@ export type GenerateDepthRecordGqlFieldsFromFields = {
>[];
depth: 0 | 1;
shouldOnlyLoadRelationIdentifiers?: boolean;
isFilesFieldMigrated?: boolean;
};
export const generateDepthRecordGqlFieldsFromFields = ({
@@ -38,7 +37,6 @@ export const generateDepthRecordGqlFieldsFromFields = ({
fields,
depth,
shouldOnlyLoadRelationIdentifiers = true,
isFilesFieldMigrated,
}: GenerateDepthRecordGqlFieldsFromFields) => {
const generatedRecordGqlFields: RecordGqlFields = fields.reduce(
(recordGqlFields, fieldMetadata) => {
@@ -88,7 +86,6 @@ export const generateDepthRecordGqlFieldsFromFields = ({
const junctionGqlFields = generateJunctionRelationGqlFields({
fieldMetadataItem: fieldMetadata,
objectMetadataItems,
isFilesFieldMigrated,
});
if (isDefined(junctionGqlFields) && depth === 1) {
@@ -103,10 +100,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem);
const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(
targetObjectMetadataItem,
isFilesFieldMigrated,
);
getImageIdentifierFieldMetadataItem(targetObjectMetadataItem);
const relationIdentifierSubGqlFields = {
id: true,
@@ -16,13 +16,11 @@ type JunctionFieldMetadataItem = Pick<
type GenerateJunctionRelationGqlFieldsArgs = {
fieldMetadataItem: JunctionFieldMetadataItem;
objectMetadataItems: JunctionObjectMetadataItem[];
isFilesFieldMigrated?: boolean;
};
const buildRegularTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id,
@@ -33,17 +31,13 @@ const buildRegularTargetFieldGqlFields = (
}
return {
[targetField.name]: buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
),
[targetField.name]: buildIdentifierGqlFields(targetObjectMetadata),
};
};
const buildMorphTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const morphRelations = targetField.morphRelations;
@@ -69,10 +63,7 @@ const buildMorphTargetFieldGqlFields = (
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
result[computedFieldName] = buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
);
result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata);
}
return result;
@@ -81,27 +72,17 @@ const buildMorphTargetFieldGqlFields = (
const buildTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
return buildMorphTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
);
return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems);
}
return buildRegularTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
);
return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems);
};
// Generates GraphQL fields for a junction relation, including the nested target objects
export const generateJunctionRelationGqlFields = ({
fieldMetadataItem,
objectMetadataItems,
isFilesFieldMigrated,
}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => {
const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings,
@@ -119,17 +100,13 @@ export const generateJunctionRelationGqlFields = ({
const junctionTargetFields = targetFields.reduce<RecordGqlFields>(
(acc, targetField) => ({
...acc,
...buildTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
),
...buildTargetFieldGqlFields(targetField, objectMetadataItems),
}),
{},
);
return {
...buildIdentifierGqlFields(junctionObjectMetadata, isFilesFieldMigrated),
...buildIdentifierGqlFields(junctionObjectMetadata),
...junctionTargetFields,
};
};
@@ -51,7 +51,12 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
avatarUrl
avatarFile {
fileId
label
extension
url
}
createdAt
deletedAt
id
@@ -9,9 +9,7 @@ import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type UseRecordsFieldVisibleGqlFields = {
objectMetadataItem: ObjectMetadataItem;
@@ -31,10 +29,6 @@ export const useRecordsFieldVisibleGqlFields = ({
const { objectMetadataItems } = useObjectMetadataItems();
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({
objectMetadataItems,
fields: visibleRecordFields
@@ -44,15 +38,12 @@ export const useRecordsFieldVisibleGqlFields = ({
)
.filter(isDefined),
depth: 1,
isFilesFieldMigrated,
});
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const imageIdentifierFieldMetadataItem = getImageIdentifierFieldMetadataItem(
objectMetadataItem,
isFilesFieldMigrated,
);
const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(objectMetadataItem);
const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem);
@@ -13,14 +13,10 @@ import { RecordTitleCell } from '@/object-record/record-title-cell/components/Re
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataType,
FeatureFlagKey,
} from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SummaryCardProps = {
objectNameSingular: string;
@@ -48,9 +44,6 @@ export const SummaryCard = ({
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
@@ -65,7 +58,6 @@ export const SummaryCard = ({
{
recordId: objectRecordId,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
},
);
@@ -1,30 +1,21 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useApolloClient } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import {
FileFolder,
useUploadFilesFieldFileMutation,
useUploadImageMutation,
FeatureFlagKey,
FieldMetadataType,
} from '~/generated-metadata/graphql';
export const usePersonAvatarUpload = (personRecordId: string) => {
const apolloClient = useApolloClient();
const [uploadImage] = useUploadImageMutation();
const [uploadFilesFieldFile] = useUploadFilesFieldFileMutation({
client: apolloClient,
});
const { updateOneRecord } = useUpdateOneRecord();
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { objectMetadataItem: personMetadata } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
@@ -35,56 +26,33 @@ export const usePersonAvatarUpload = (personRecordId: string) => {
)?.id;
const onUploadPicture = async (file: File) => {
if (isFilesFieldMigrated) {
assertIsDefinedOrThrow(
avatarFileFieldMetadataId,
new Error(t`Avatar file field not found for person object`),
);
assertIsDefinedOrThrow(
avatarFileFieldMetadataId,
new Error(t`Avatar file field not found for person object`),
);
const result = await uploadFilesFieldFile({
variables: { file, fieldMetadataId: avatarFileFieldMetadataId },
});
const result = await uploadFilesFieldFile({
variables: { file, fieldMetadataId: avatarFileFieldMetadataId },
});
const uploadedFile = result?.data?.uploadFilesFieldFile;
const uploadedFile = result?.data?.uploadFilesFieldFile;
if (!isDefined(uploadedFile)) {
return;
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
idToUpdate: personRecordId,
updateOneRecordInput: {
avatarFile: [
{
fileId: uploadedFile.id,
label: file.name,
},
],
},
});
} else {
const result = await uploadImage({
variables: {
file,
fileFolder: FileFolder.PersonPicture,
},
});
const avatarSignedFile = result?.data?.uploadImage;
if (!avatarSignedFile) {
return;
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
idToUpdate: personRecordId,
updateOneRecordInput: {
avatarUrl: avatarSignedFile.path,
},
});
if (!isDefined(uploadedFile)) {
return;
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
idToUpdate: personRecordId,
updateOneRecordInput: {
avatarFile: [
{
fileId: uploadedFile.id,
label: file.name,
},
],
},
});
};
return { onUploadPicture };
@@ -8,7 +8,6 @@ import { uncapitalize } from 'twenty-shared/utils';
type RecordStoreIdentifierFamilyKey = {
recordId: string;
allowRequestsToTwentyIcons: boolean;
isFilesFieldMigrated?: boolean;
};
export const recordStoreIdentifierFamilySelector = createAtomFamilySelector<
@@ -20,7 +19,6 @@ export const recordStoreIdentifierFamilySelector = createAtomFamilySelector<
({
recordId,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}: RecordStoreIdentifierFamilyKey) =>
({ get }) => {
const recordFromStore = get(recordStoreFamilyState, recordId);
@@ -42,7 +40,6 @@ export const recordStoreIdentifierFamilySelector = createAtomFamilySelector<
objectMetadataItem: objectMetadataItem,
record: recordFromStore,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
});
},
});
@@ -17,7 +17,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getRecordChipGenerators = (
objectMetadataItems: ObjectMetadataItem[],
allowRequestsToTwentyIcons?: boolean,
isFilesFieldMigrated?: boolean,
) => {
const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName =
{};
@@ -95,7 +94,6 @@ export const getRecordChipGenerators = (
record,
imageIdentifierFieldMetadataToUse,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
),
avatarType,
isLabelIdentifier,
@@ -1,10 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE_LEGACY = gql`
mutation UploadWorkspaceMemberProfilePictureLegacy($file: Upload!) {
uploadWorkspaceMemberProfilePictureLegacy(file: $file) {
path
token
}
}
`;
@@ -8,13 +8,8 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
useUploadWorkspaceMemberProfilePictureLegacyMutation,
useUploadWorkspaceMemberProfilePictureMutation,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { useUploadWorkspaceMemberProfilePictureMutation } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type WorkspaceMemberPictureUploaderProps = {
@@ -30,9 +25,6 @@ export const WorkspaceMemberPictureUploader = ({
onAvatarUpdated,
disabled = false,
}: WorkspaceMemberPictureUploaderProps) => {
const isCorePictureMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
);
const { enqueueErrorSnackBar } = useSnackBar();
const [isUploading, setIsUploading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -44,8 +36,6 @@ export const WorkspaceMemberPictureUploader = ({
);
const [uploadPicture] = useUploadWorkspaceMemberProfilePictureMutation();
const [uploadPictureLegacy] =
useUploadWorkspaceMemberProfilePictureLegacyMutation();
const { updateOneRecord } = useUpdateOneRecord();
@@ -67,52 +57,28 @@ export const WorkspaceMemberPictureUploader = ({
let newAvatarUrl: string | null = null;
try {
if (!isCorePictureMigrated) {
const { data } = await uploadPictureLegacy({
variables: { file },
context: {
fetchOptions: {
signal: controller.signal,
},
const { data } = await uploadPicture({
variables: { file },
context: {
fetchOptions: {
signal: controller.signal,
},
});
},
});
const signedFile = data?.uploadWorkspaceMemberProfilePictureLegacy;
if (!isDefined(signedFile)) {
throw new Error('Avatar upload failed');
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: signedFile.path },
});
newAvatarUrl = buildSignedPath(signedFile);
} else {
const { data } = await uploadPicture({
variables: { file },
context: {
fetchOptions: {
signal: controller.signal,
},
},
});
const signedFile = data?.uploadWorkspaceMemberProfilePicture;
if (!isDefined(signedFile)) {
throw new Error('Avatar upload failed');
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: signedFile.url },
});
newAvatarUrl = signedFile.url;
const signedFile = data?.uploadWorkspaceMemberProfilePicture;
if (!isDefined(signedFile)) {
throw new Error('Avatar upload failed');
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: signedFile.url },
});
newAvatarUrl = signedFile.url;
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
setCurrentWorkspaceMember({
...currentWorkspaceMember,
@@ -1,21 +1,13 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { buildSignedPath } from 'twenty-shared/utils';
import {
FeatureFlagKey,
useUpdateWorkspaceMutation,
useUploadWorkspaceLogoLegacyMutation,
useUploadWorkspaceLogoMutation,
} from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const WorkspaceLogoUploader = () => {
const isCorePictureMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
);
const [uploadLogoLegacy] = useUploadWorkspaceLogoLegacyMutation();
const [uploadLogo] = useUploadWorkspaceLogoMutation();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
@@ -30,31 +22,17 @@ export const WorkspaceLogoUploader = () => {
throw new Error('Workspace id not found');
}
if (isCorePictureMigrated) {
await uploadLogo({
variables: {
file,
},
onCompleted: (data) => {
setCurrentWorkspace({
...currentWorkspace,
logo: data.uploadWorkspaceLogo.url,
});
},
});
} else {
await uploadLogoLegacy({
variables: {
file,
},
onCompleted: (data) => {
setCurrentWorkspace({
...currentWorkspace,
logo: buildSignedPath(data.uploadWorkspaceLogoLegacy),
});
},
});
}
await uploadLogo({
variables: {
file,
},
onCompleted: (data) => {
setCurrentWorkspace({
...currentWorkspace,
logo: data.uploadWorkspaceLogo.url,
});
},
});
};
const onRemove = async () => {
@@ -1,20 +1,13 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { getMissingDraftEmailScopes } from '@/accounts/utils/hasMissingDraftEmailScopes';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import {
CoreObjectNameSingular,
ConnectedAccountProvider,
SettingsPath,
} from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -22,17 +15,18 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowEmailAction } from '@/workflow/types/WorkflowEmailAction';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useEmailForm } from '@/workflow/workflow-steps/workflow-actions/hooks/useEmailForm';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { t } from '@lingui/core/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect, useState } from 'react';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type WorkflowEmailAction } from '@/workflow/types/WorkflowEmailAction';
import { Callout, IconPlus } from 'twenty-ui/display';
import { Button, type SelectOption } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
@@ -60,8 +54,6 @@ export const WorkflowEditActionEmailBase = ({
}: WorkflowEditActionEmailBaseProps) => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth();
const { enqueueErrorSnackBar } = useSnackBar();
const { uploadAttachmentFile } = useUploadAttachmentFile();
const workflowVisualizerWorkflowId = useAtomComponentStateValue(
workflowVisualizerWorkflowIdComponentState,
@@ -114,25 +106,6 @@ export const WorkflowEditActionEmailBase = ({
handleFieldChange('connectedAccountId', connectedAccountId);
};
const handleUploadAttachment = async (file: File) => {
if (!isDefined(workflowVisualizerWorkflowId)) {
return undefined;
}
const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, {
id: workflowVisualizerWorkflowId,
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
});
return attachmentAbsoluteURL;
};
const handleImageUploadError = (_: Error, file: File) => {
enqueueErrorSnackBar({
message: t`Failed to upload image: `.concat(file.name),
});
};
const filter: { or: object[] } = {
or: [
{
@@ -380,8 +353,6 @@ export const WorkflowEditActionEmailBase = ({
children: t`Email Editor`,
},
]}
onImageUpload={handleUploadAttachment}
onImageUploadError={handleImageUploadError}
minHeight={EMAIL_EDITOR_MIN_HEIGHT}
maxWidth={EMAIL_EDITOR_MAX_WIDTH}
/>
@@ -1,10 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_WORKSPACE_LOGO_LEGACY = gql`
mutation UploadWorkspaceLogoLegacy($file: Upload!) {
uploadWorkspaceLogoLegacy(file: $file) {
path
token
}
}
`;
@@ -1,531 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } 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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import {
LogicFunctionEntity,
LogicFunctionRuntime,
} from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import {
DEFAULT_BUILT_HANDLER_PATH,
DEFAULT_HANDLER_NAME,
DEFAULT_SOURCE_HANDLER_PATH,
} from 'src/engine/metadata-modules/logic-function/constants/handler.contant';
const OLD_BUILT_FOLDER = 'built-function';
const OLD_SOURCE_FOLDER = 'serverless-function';
const SEED_VERSION_DRAFT = 'draft';
const SEED_VERSION_PUBLISHED = '1';
const OUTPUT_SCHEMA_LINK = {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
} as const;
const OUTPUT_SCHEMA_MESSAGE = {
message: {
type: 'string',
label: 'message',
value: 'Hello, input: null and null',
isLeaf: true,
},
} as const;
@Command({
name: 'upgrade:1-17:seed-workflow-v1-16',
description:
'[Temporary] Clean existing workflow runs, workflow versions, workflows, logic functions and old file storage, then seed 3 scenarios: (1) draft+active with LINK outputSchema, (2) draft-only with message outputSchema, (3) draft+active with mixed outputSchema (message on draft, LINK on active). For testing the 1-17 migrate-workflow-code-steps command.',
})
export class SeedWorkflowV1_16Command extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(SeedWorkflowV1_16Command.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly applicationService: ApplicationService,
private readonly fileStorageService: FileStorageService,
private readonly recordPositionService: RecordPositionService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(`Seeding workflow v1.16 data for workspace ${workspaceId}`);
await this.cleanWorkflowsAndOldFileStorage(workspaceId);
const workflowRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflow',
{
shouldBypassPermissionChecks: true,
},
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await this.seedScenarioDraftAndActiveLink(
workspaceId,
workflowRepository,
workflowVersionRepository,
);
await this.seedScenarioDraftOnlyMessage(
workspaceId,
workflowRepository,
workflowVersionRepository,
);
await this.seedScenarioDraftAndActiveMixedOutputSchema(
workspaceId,
workflowRepository,
workflowVersionRepository,
);
this.logger.log(
`Seeded 3 workflows (draft+active LINK, draft-only message, draft+active mixed outputSchema) in workspace ${workspaceId}. Run upgrade:1-17:migrate-workflow-code-steps to test migration.`,
);
}
private buildCodeStep(
logicFunctionId: string,
version: string,
outputSchema: object,
stepName: string,
) {
return {
id: uuidv4(),
name: stepName,
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: logicFunctionId,
serverlessFunctionInput: {},
serverlessFunctionVersion: version,
},
outputSchema,
},
valid: true,
};
}
private buildTrigger(): {
name: string;
type: string;
settings: { outputSchema: object };
} {
return {
name: 'trigger',
type: WorkflowTriggerType.MANUAL,
settings: { outputSchema: {} },
};
}
private async seedScenarioDraftAndActiveLink(
workspaceId: string,
workflowRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
workflowVersionRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
): Promise<void> {
const logicFunctionId = await this.insertLogicFunctionRow(
workspaceId,
'Seed (draft+active LINK)',
);
await this.writeOldFormatFiles(logicFunctionId, SEED_VERSION_DRAFT);
await this.writeOldFormatFiles(logicFunctionId, SEED_VERSION_PUBLISHED);
const workflowId = uuidv4();
const draftVersionId = uuidv4();
const activeVersionId = uuidv4();
const workflowPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflow',
},
workspaceId,
});
const draftVersionPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflowVersion',
},
workspaceId,
});
const activeVersionPosition =
await this.recordPositionService.buildRecordPosition({
value: 'last',
objectMetadata: {
isCustom: false,
nameSingular: 'workflowVersion',
},
workspaceId,
});
await workflowRepository.insert({
id: workflowId,
name: 'Seed draft+active (LINK)',
statuses: [WorkflowStatus.DRAFT],
position: workflowPosition,
});
const trigger = this.buildTrigger();
const draftSteps = [
this.buildCodeStep(
logicFunctionId,
SEED_VERSION_DRAFT,
OUTPUT_SCHEMA_LINK,
'Code step (draft)',
),
];
const activeSteps = [
this.buildCodeStep(
logicFunctionId,
SEED_VERSION_PUBLISHED,
OUTPUT_SCHEMA_LINK,
'Code step (v1)',
),
];
await workflowVersionRepository.insert({
id: draftVersionId,
workflowId,
name: 'v1',
status: WorkflowVersionStatus.DRAFT,
trigger,
steps: draftSteps,
position: draftVersionPosition,
});
await workflowVersionRepository.insert({
id: activeVersionId,
workflowId,
name: 'v2',
status: WorkflowVersionStatus.ACTIVE,
trigger,
steps: activeSteps,
position: activeVersionPosition,
});
await workflowRepository.update(workflowId, {
lastPublishedVersionId: activeVersionId,
statuses: [WorkflowStatus.ACTIVE],
});
}
private async seedScenarioDraftOnlyMessage(
workspaceId: string,
workflowRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
workflowVersionRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
): Promise<void> {
const logicFunctionId = await this.insertLogicFunctionRow(
workspaceId,
'Seed (draft-only message)',
);
await this.writeOldFormatFiles(logicFunctionId, SEED_VERSION_DRAFT);
const workflowId = uuidv4();
const draftVersionId = uuidv4();
const workflowPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflow',
},
workspaceId,
});
const draftVersionPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflowVersion',
},
workspaceId,
});
await workflowRepository.insert({
id: workflowId,
name: 'Seed draft-only (message)',
statuses: [WorkflowStatus.DRAFT],
position: workflowPosition,
});
const trigger = this.buildTrigger();
const draftSteps = [
this.buildCodeStep(
logicFunctionId,
SEED_VERSION_DRAFT,
OUTPUT_SCHEMA_MESSAGE,
'Code step (draft)',
),
];
await workflowVersionRepository.insert({
id: draftVersionId,
workflowId,
name: 'v1',
status: WorkflowVersionStatus.DRAFT,
trigger,
steps: draftSteps,
position: draftVersionPosition,
});
}
private async seedScenarioDraftAndActiveMixedOutputSchema(
workspaceId: string,
workflowRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
workflowVersionRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
): Promise<void> {
const logicFunctionId = await this.insertLogicFunctionRow(
workspaceId,
'Seed (draft+active mixed)',
);
await this.writeOldFormatFiles(logicFunctionId, SEED_VERSION_DRAFT);
await this.writeOldFormatFiles(logicFunctionId, SEED_VERSION_PUBLISHED);
const workflowId = uuidv4();
const draftVersionId = uuidv4();
const activeVersionId = uuidv4();
const workflowPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflow',
},
workspaceId,
});
const draftVersionPosition =
await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: {
isCustom: false,
nameSingular: 'workflowVersion',
},
workspaceId,
});
const activeVersionPosition =
await this.recordPositionService.buildRecordPosition({
value: 'last',
objectMetadata: {
isCustom: false,
nameSingular: 'workflowVersion',
},
workspaceId,
});
await workflowRepository.insert({
id: workflowId,
name: 'Seed draft+active (mixed)',
statuses: [WorkflowStatus.DRAFT],
position: workflowPosition,
});
const trigger = this.buildTrigger();
const draftSteps = [
this.buildCodeStep(
logicFunctionId,
SEED_VERSION_DRAFT,
OUTPUT_SCHEMA_MESSAGE,
'Code step (draft, message)',
),
];
const activeSteps = [
this.buildCodeStep(
logicFunctionId,
SEED_VERSION_PUBLISHED,
OUTPUT_SCHEMA_LINK,
'Code step (v1, LINK)',
),
];
await workflowVersionRepository.insert({
id: draftVersionId,
workflowId,
name: 'v1',
status: WorkflowVersionStatus.DRAFT,
trigger,
steps: draftSteps,
position: draftVersionPosition,
});
await workflowVersionRepository.insert({
id: activeVersionId,
workflowId,
name: 'v2',
status: WorkflowVersionStatus.ACTIVE,
trigger,
steps: activeSteps,
position: activeVersionPosition,
});
await workflowRepository.update(workflowId, {
lastPublishedVersionId: activeVersionId,
statuses: [WorkflowStatus.ACTIVE],
});
}
private async insertLogicFunctionRow(
workspaceId: string,
name: string = 'Seed code step (v1.16)',
): Promise<string> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const applicationId = workspaceCustomFlatApplication.id;
const id = uuidv4();
const universalIdentifier = uuidv4();
const now = new Date();
await this.logicFunctionRepository.insert({
id,
workspaceId,
universalIdentifier,
applicationId,
name,
description: 'Temporary logic function for 1.17 migration testing',
sourceHandlerPath: DEFAULT_SOURCE_HANDLER_PATH,
builtHandlerPath: DEFAULT_BUILT_HANDLER_PATH,
handlerName: DEFAULT_HANDLER_NAME,
runtime: LogicFunctionRuntime.NODE22,
timeoutSeconds: 300,
checksum: null,
toolInputSchema: null,
isTool: false,
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
return id;
}
private async cleanWorkflowsAndOldFileStorage(
workspaceId: string,
): Promise<void> {
const workflowRunRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowRun',
{ shouldBypassPermissionChecks: true },
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflow',
{
shouldBypassPermissionChecks: true,
},
);
const deletedRuns = await workflowRunRepository.delete({});
const deletedVersions = await workflowVersionRepository.delete({});
const deletedWorkflows = await workflowRepository.delete({});
const deletedLogicFunctions = await this.logicFunctionRepository.delete({
workspaceId,
});
this.logger.log(
`Cleaned workspace ${workspaceId}: ${deletedRuns.affected ?? 0} workflow run(s), ${deletedVersions.affected ?? 0} workflow version(s), ${deletedWorkflows.affected ?? 0} workflow(s), ${deletedLogicFunctions.affected ?? 0} logic function(s)`,
);
try {
await this.fileStorageService.deleteLegacy({
folderPath: OLD_BUILT_FOLDER,
});
await this.fileStorageService.deleteLegacy({
folderPath: OLD_SOURCE_FOLDER,
});
this.logger.log(
`Cleaned old file storage: ${OLD_BUILT_FOLDER}, ${OLD_SOURCE_FOLDER}`,
);
} catch (error) {
this.logger.warn(
`Old file storage cleanup skipped (folders may not exist): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async writeOldFormatFiles(
logicFunctionId: string,
version: string,
): Promise<void> {
const builtFolder = `${OLD_BUILT_FOLDER}/${logicFunctionId}/${version}`;
const sourceFolder = `${OLD_SOURCE_FOLDER}/${logicFunctionId}/${version}`;
const builtSources = {
'index.mjs':
'export default async function main() { return { message: "ok" }; }\n',
};
const sourceSources = {
src: {
'index.ts':
'export default async function main(): Promise<{ message: string }> {\n return { message: "ok" };\n}\n',
},
};
await this.fileStorageService.writeFolderLegacy(builtSources, builtFolder);
await this.fileStorageService.writeFolderLegacy(
sourceSources,
sourceFolder,
);
}
}
@@ -11,7 +11,6 @@ import { MigrateDateTimeIsFilterValuesCommand } from 'src/database/commands/upgr
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command';
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
import { SeedWorkflowV1_16Command } from 'src/database/commands/upgrade-version-command/1-17/1-17-seed-workflow-v1-16.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -76,7 +75,6 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
DeleteFileRecordsAndUpdateTableCommand,
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
SeedWorkflowV1_16Command,
BackfillApplicationPackageFilesCommand,
],
exports: [
@@ -89,7 +87,6 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
DeleteFileRecordsAndUpdateTableCommand,
SeedWorkflowV1_16Command,
BackfillApplicationPackageFilesCommand,
],
})
@@ -3,7 +3,7 @@ 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, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
@@ -14,7 +14,6 @@ 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 { 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 { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -36,7 +35,7 @@ type RichTextBlock = Record<string, unknown>;
@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',
'[DEPRECATED] Migrate activity rich text blocks - this migration is now complete and no longer needed',
})
export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -44,7 +43,6 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
@@ -57,23 +55,20 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isFilesFieldMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId,
this.logger.log(
`[DEPRECATED] Activity rich text attachment file IDs migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
if (isFilesFieldMigrated) {
this.logger.log(
`Files field already migrated for workspace ${workspaceId}, skipping`,
);
return;
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting activity rich text attachment file IDs migration for workspace ${workspaceId}`,
);
@@ -171,18 +166,11 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
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}`,
);
}, systemAuthContext);
}
};
private async migrateActivityTable({
activityRepository,
@@ -3,11 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
FileFolder,
FeatureFlagKey,
} from 'twenty-shared/types';
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
@@ -19,7 +15,6 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -35,7 +30,7 @@ import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objec
@Command({
name: 'upgrade:1-18:migrate-attachment-files',
description:
'Migrate attachment files to file field: copy files and create file records',
'[DEPRECATED] Migrate attachment files to file field - this migration is now complete and no longer needed',
})
export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -43,7 +38,6 @@ export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMi
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fieldMetadataService: FieldMetadataService,
@@ -56,23 +50,20 @@ export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMi
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId,
this.logger.log(
`[DEPRECATED] Attachment files migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
if (isMigrated) {
this.logger.log(
`Attachment files migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting attachment files migration for workspace ${workspaceId}`,
);
@@ -302,5 +293,5 @@ export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMi
`${isDryRun ? '[DRY RUN] ' : ''}Completed attachment files migration for workspace ${workspaceId}`,
);
}, systemAuthContext);
}
};
}
@@ -3,11 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
FileFolder,
FeatureFlagKey,
} from 'twenty-shared/types';
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
import {
assertIsDefinedOrThrow,
extractFolderPathFilenameAndTypeOrThrow,
@@ -20,7 +16,6 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -36,7 +31,7 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
@Command({
name: 'upgrade:1-18:migrate-person-avatar-files',
description:
'Migrate person avatarUrl files to file field: copy files and create file records',
'[DEPRECATED] Migrate person avatarUrl files to file field - this migration is now complete and no longer needed',
})
export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -44,7 +39,6 @@ export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspaces
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fieldMetadataService: FieldMetadataService,
@@ -57,23 +51,20 @@ export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspaces
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId,
this.logger.log(
`[DEPRECATED] Person avatar files migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
if (isMigrated) {
this.logger.log(
`Person avatar files migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
this.logger.log(
`${
isDryRun ? '[DRY RUN] ' : ''
@@ -304,5 +295,5 @@ export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspaces
`${isDryRun ? '[DRY RUN] ' : ''}Completed person avatar files migration for workspace ${workspaceId}`,
);
}, systemAuthContext);
}
};
}
@@ -3,14 +3,13 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { DataSource, In, Repository } from 'typeorm';
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 { 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -47,7 +46,7 @@ type SendEmailStep = {
@Command({
name: 'upgrade:1-18:migrate-workflow-send-email-attachments',
description:
'Migrate workflow send email attachments to FileFolder.Workflow and update payload paths',
'[DEPRECATED] Migrate workflow send email attachments - this migration is now complete and no longer needed',
})
export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
@@ -59,7 +58,6 @@ export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspende
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectDataSource()
@@ -70,14 +68,21 @@ export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspende
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
this.logger.log(
`[DEPRECATED] Workflow send email attachments migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_OTHER_FILE_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
const isMigrated = false;
if (isMigrated) {
this.logger.log(
@@ -199,15 +204,8 @@ export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspende
}
}
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_OTHER_FILE_MIGRATED],
workspaceId,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workflow send email attachments migration for workspace ${workspaceId}`,
);
}
};
}
@@ -4,7 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import FileType from 'file-type';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
@@ -16,7 +16,6 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
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 { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -33,7 +32,7 @@ import { getImageBufferFromUrl } from 'src/utils/image';
@Command({
name: 'upgrade:1-18:migrate-workspace-pictures',
description:
'Migrate workspace logos and workspace member avatars to file records',
'[DEPRECATED] Migrate workspace logos and workspace member avatars to file records - this migration is now complete and no longer needed',
})
export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -41,7 +40,6 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
@@ -55,59 +53,10 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId,
);
if (isMigrated) {
this.logger.log(
`Workspace pictures migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting workspace pictures migration for workspace ${workspaceId}`,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
await this.migrateWorkspaceLogo({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
await this.migrateWorkspaceMemberAvatars({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CORE_PICTURE_MIGRATED],
workspaceId,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workspace pictures migration for workspace ${workspaceId}`,
`[DEPRECATED] Workspace pictures migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_CORE_PICTURE_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
@@ -426,12 +375,5 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
throw error;
}
}
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CORE_PICTURE_MIGRATED],
workspaceId,
);
}
}
}
@@ -0,0 +1,37 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class ReplaceFileUrlWithFileRelationInAgentMessagePart1772555830171
implements MigrationInterface
{
name = 'ReplaceFileUrlWithFileRelationInAgentMessagePart1772555830171';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" DROP COLUMN "fileUrl"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" DROP COLUMN "fileMediaType"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ADD "fileId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ADD CONSTRAINT "FK_f3865544cee5742b5f5dd7340ef" FOREIGN KEY ("fileId") REFERENCES "core"."file"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" DROP CONSTRAINT "FK_f3865544cee5742b5f5dd7340ef"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" DROP COLUMN "fileId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ADD "fileMediaType" character varying`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ADD "fileUrl" character varying`,
);
}
}
@@ -15,7 +15,6 @@ import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/c
import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler';
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -43,7 +42,6 @@ export class CommonResultGettersService {
constructor(
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeObjectHandlers();
this.initializeFieldHandlers();
@@ -55,11 +53,7 @@ export class CommonResultGettersService {
['person', new PersonQueryResultGetterHandler(this.fileService)],
[
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
this.fileUrlService,
),
new WorkspaceMemberQueryResultGetterHandler(this.fileUrlService),
],
]);
}
@@ -75,11 +69,7 @@ export class CommonResultGettersService {
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(
this.fileService,
this.fileUrlService,
this.featureFlagService,
),
new RichTextV2FieldQueryResultGetterHandler(this.fileUrlService),
],
]);
}
@@ -1,9 +1,7 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
const baseRecord: ObjectRecord = {
@@ -20,28 +18,17 @@ const richTextFieldMetadata = [
},
] as FlatFieldMetadata[];
const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
const mockFileUrlService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
signFileByIdUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileUrlService;
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockReturnValue(true),
} as unknown as FeatureFlagService;
describe('RichTextV2FieldQueryResultGetterHandler', () => {
let handler: RichTextV2FieldQueryResultGetterHandler;
beforeEach(() => {
process.env.SERVER_URL = 'https://my-domain.twenty.com';
handler = new RichTextV2FieldQueryResultGetterHandler(
mockFileService,
mockFileUrlService,
mockFeatureFlagService,
);
handler = new RichTextV2FieldQueryResultGetterHandler(mockFileUrlService);
});
afterEach(() => {
@@ -169,54 +156,6 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
});
});
describe('should sign internal image URLs', () => {
it('when image block has an internal attachment URL (legacy path)', async () => {
jest
.spyOn(mockFeatureFlagService, 'isFeatureEnabled')
.mockResolvedValue(false);
const imageBlock = {
type: 'image',
props: {
name: 'photo.jpg',
url: 'https://my-domain.twenty.com/files/attachment/some-token/photo.jpg',
caption: '',
},
children: [],
};
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([imageBlock]),
},
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual({
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{
...imageBlock,
props: {
...imageBlock.props,
url: 'https://my-domain.twenty.com/files/signed-path',
},
},
]),
},
});
});
});
describe('should handle multiple RICH_TEXT_V2 fields', () => {
it('when record has multiple rich text fields', async () => {
const multiFieldMetadata = [
@@ -1,5 +1,4 @@
import {
FeatureFlagKey,
FieldMetadataType,
FileFolder,
type ObjectRecord,
@@ -8,10 +7,8 @@ import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -36,11 +33,7 @@ const parseBlocknoteJsonSafely = (
export class RichTextV2FieldQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {}
constructor(private readonly fileUrlService: FileUrlService) {}
async handle(
record: ObjectRecord,
@@ -55,11 +48,6 @@ export class RichTextV2FieldQueryResultGetterHandler
return record;
}
const isFilesFieldMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId,
);
for (const field of richTextV2Fields) {
const fieldValue = record[field.name];
const blocknoteJson = fieldValue?.blocknote;
@@ -77,7 +65,6 @@ export class RichTextV2FieldQueryResultGetterHandler
const signedBlocks = this.signBlocknoteImageUrls(
blocknoteBlocks,
workspaceId,
isFilesFieldMigrated,
);
record[field.name] = {
@@ -92,69 +79,32 @@ export class RichTextV2FieldQueryResultGetterHandler
signBlocknoteImageUrls = (
blocknoteBlocks: RichTextBlock[],
workspaceId: string,
isFilesFieldMigrated: boolean,
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (isFilesFieldMigrated && isDefined(block.props?.url)) {
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
...block,
props: {
...block.props,
url,
},
};
}
if (block.type !== 'image' || !block.props?.url) {
if (!isDefined(block.props?.url)) {
return block;
}
let url: URL;
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
try {
url = new URL(block.props.url);
} catch {
if (!isDefined(fileIdFromUrl)) {
return block;
}
const pathname = url.pathname;
const isLinkExternal = !pathname.startsWith('/files/attachment/');
if (isLinkExternal) {
return block;
}
const fileName = pathname.match(/files\/attachment\/(?:.+)\/(.+)$/)?.[1];
if (!isDefined(fileName)) {
return block;
}
const signedPath = this.fileService.signFileUrl({
url: `attachment/${fileName}`,
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
...block,
props: {
...block.props,
url: `${process.env.SERVER_URL}/files/${signedPath}`,
url,
},
};
});
@@ -1,22 +1,16 @@
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export class WorkspaceMemberQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileUrlService: FileUrlService,
) {}
constructor(private readonly fileUrlService: FileUrlService) {}
async handle(
workspaceMember: WorkspaceMemberWorkspaceEntity,
@@ -26,41 +20,24 @@ export class WorkspaceMemberQueryResultGetterHandler
return workspaceMember;
}
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId,
)
) {
const fileId = extractFileIdFromUrl(
workspaceMember.avatarUrl,
FileFolder.CorePicture,
);
const fileId = extractFileIdFromUrl(
workspaceMember.avatarUrl,
FileFolder.CorePicture,
);
if (!isDefined(fileId)) {
return workspaceMember;
}
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...workspaceMember,
avatarUrl: signedUrl,
};
if (!isDefined(fileId)) {
return workspaceMember;
}
const signedPath = this.fileService.signFileUrl({
url: workspaceMember.avatarUrl,
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...workspaceMember,
avatarUrl: signedPath,
avatarUrl: signedUrl,
};
}
}
@@ -4,9 +4,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
@@ -39,7 +39,6 @@ import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -78,7 +77,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
@Module({
imports: [
JwtModule,
FileUploadModule,
DataSourceModule,
WorkspaceDomainsModule,
TokenModule,
@@ -1,8 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -74,81 +72,6 @@ describe('FileStorageService', () => {
mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
});
describe('writeFileLegacy', () => {
it('should delegate to the current driver', async () => {
const writeParams = {
file: Buffer.from('test content'),
name: 'test.txt',
folder: 'documents',
mimeType: 'text/plain',
};
mockDriver.writeFile.mockResolvedValue(undefined);
await service.writeFileLegacy(writeParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.writeFile).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
sourceFile: writeParams.file,
mimeType: 'text/plain',
});
});
it('should handle write errors', async () => {
const writeParams = {
file: 'test content',
name: 'test.txt',
folder: 'documents',
mimeType: 'text/plain',
};
const error = new Error('Write failed');
mockDriver.writeFile.mockRejectedValue(error);
await expect(service.writeFileLegacy(writeParams)).rejects.toThrow(
'Write failed',
);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
});
});
describe('readFileLegacy', () => {
it('should delegate to the current driver', async () => {
const readParams = {
filePath: 'documents/test.txt',
};
const mockStream = new Readable();
mockDriver.readFile.mockResolvedValue(mockStream);
const result = await service.readFileLegacy(readParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.readFile).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
});
expect(result).toBe(mockStream);
});
it('should handle read errors', async () => {
const readParams = {
filePath: 'documents/test.txt',
};
const error = new Error('Read failed');
mockDriver.readFile.mockRejectedValue(error);
await expect(service.readFileLegacy(readParams)).rejects.toThrow(
'Read failed',
);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
});
});
describe('deleteLegacy', () => {
it('should delegate to the current driver with filename', async () => {
const deleteParams = {
@@ -1,12 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { mkdir, readdir, readFile, stat } from 'fs/promises';
import { basename, dirname, join } from 'path';
import { type Readable } from 'stream';
import { isObject } from '@sniptt/guards';
import { FileFolder, Sources } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { Like, Repository, type QueryRunner } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -45,23 +43,6 @@ export class FileStorageService {
).replace(/\/+/g, '/');
}
writeFileLegacy(params: {
file: string | Buffer | Uint8Array;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
const { file, name, folder, mimeType } = params;
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.writeFile({
filePath: `${folder}/${name}`,
sourceFile: file,
mimeType,
});
}
async writeFile({
sourceFile,
mimeType,
@@ -133,12 +114,6 @@ export class FileStorageService {
});
}
readFileLegacy(params: { filePath: string }): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.readFile(params);
}
readFile(params: ResourceIdentifier): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
@@ -147,56 +122,6 @@ export class FileStorageService {
return driver.readFile({ filePath: onStoragePath });
}
async writeFolderLegacy(sources: Sources, folderPath: string): Promise<void> {
for (const key of Object.keys(sources)) {
if (isObject(sources[key])) {
await this.writeFolderLegacy(sources[key], join(folderPath, key));
continue;
}
await this.writeFileLegacy({
file: sources[key],
name: key,
folder: folderPath,
mimeType: undefined,
});
}
}
async readFolderLegacy(
folderPath: string,
localTempPath?: string,
): Promise<Sources> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const tempDir = localTempPath || `/tmp/twenty-read-folder-${Date.now()}`;
await mkdir(tempDir, { recursive: true });
await driver.downloadFolder({
onStoragePath: folderPath,
localPath: tempDir,
});
return this.readLocalFolderToSources(tempDir);
}
private async readLocalFolderToSources(localPath: string): Promise<Sources> {
const sources: Sources = {};
const entries = await readdir(localPath);
for (const entry of entries) {
const entryPath = join(localPath, entry);
const stats = await stat(entryPath);
if (stats.isFile()) {
sources[entry] = await readFile(entryPath, 'utf8');
} else {
sources[entry] = await this.readLocalFolderToSources(entryPath);
}
}
return sources;
}
downloadFile(
params: ResourceIdentifier & { localPath: string },
): Promise<void> {
@@ -15,7 +15,6 @@ import {
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileByIdGuard } from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -36,7 +35,6 @@ const createMockStream = (): Readable => {
describe('FileController', () => {
let controller: FileController;
let fileService: FileService;
const mock_FilePathGuard: CanActivate = { canActivate: jest.fn(() => true) };
const mock_FileByIdGuard: CanActivate = { canActivate: jest.fn(() => true) };
const mock_PublicEndpointGuard: CanActivate = {
canActivate: jest.fn(() => true),
@@ -52,15 +50,12 @@ describe('FileController', () => {
{
provide: FileService,
useValue: {
getFileStream: jest.fn(),
getFileStreamById: jest.fn(),
getFileStreamByPath: jest.fn(),
},
},
],
})
.overrideGuard(FilePathGuard)
.useValue(mock_FilePathGuard)
.overrideGuard(FileByIdGuard)
.useValue(mock_FileByIdGuard)
.overrideGuard(PublicEndpointGuard)
@@ -79,50 +74,6 @@ describe('FileController', () => {
expect(controller).toBeDefined();
});
describe('getFile', () => {
it('should extract folder, token and filename from 3-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStream').mockResolvedValue(mockStream);
const mockRequest = {
path: '/files/attachment/test-token/test-file.csv',
workspaceId: 'workspace-id',
} as any;
const mockResponse = {} as any;
await controller.getFile(mockResponse, mockRequest);
expect(fileService.getFileStream).toHaveBeenCalledWith(
'attachment',
'test-file.csv',
'workspace-id',
);
});
it('should extract folder with size, token and filename from 4-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStream').mockResolvedValue(mockStream);
const mockRequest = {
path: '/files/profile-picture/original/test-token/avatar.jpg',
workspaceId: 'workspace-id',
} as any;
const mockResponse = {} as any;
await controller.getFile(mockResponse, mockRequest);
expect(fileService.getFileStream).toHaveBeenCalledWith(
'profile-picture/original',
'avatar.jpg',
'workspace-id',
);
});
});
describe('getFileById', () => {
it('should call fileService.getFileStreamById and pipe the result', async () => {
const mockStream = createMockStream();
@@ -23,15 +23,13 @@ import {
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import {
FileByIdGuard,
SupportedFileFolder,
} from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller()
@UseFilters(FileApiExceptionFilter)
@@ -83,47 +81,6 @@ export class FileController {
}
}
@Get('files/*path')
@UseGuards(FilePathGuard, NoPermissionGuard)
async getFile(@Res() res: Response, @Req() req: Request) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workspaceId = (req as any)?.workspaceId;
const { rawFolder, filename } = extractFileInfoFromRequest(req);
try {
const fileStream = await this.fileService.getFileStream(
rawFolder,
filename,
workspaceId,
);
fileStream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
fileStream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}
throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
@Get('file/:fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileAIChatResolver } from 'src/engine/core-modules/file/file-ai-chat/resolvers/file-ai-chat.resolver';
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [FileUrlModule, ApplicationModule, PermissionsModule],
providers: [FileAIChatService, FileAIChatResolver],
exports: [FileAIChatService],
})
export class FileAIChatModule {}
@@ -7,9 +7,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.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';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -22,46 +21,24 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@MetadataResolver()
export class FileResolver {
constructor(private readonly fileMetadataService: FileMetadataService) {}
export class FileAIChatResolver {
constructor(private readonly fileAIChatService: FileAIChatService) {}
@Mutation(() => FileDTO, {
deprecationReason: 'Use specific file service instead',
})
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async createFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
async uploadAIChatFile(
@AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<FileDTO> {
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return this.fileMetadataService.createFile({
return await this.fileAIChatService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
workspaceId,
});
}
@Mutation(() => FileDTO, {
deprecationReason: '',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async deleteFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('fileId', { type: () => UUIDScalarType }) fileId: string,
): Promise<FileDTO> {
const deletedFile = await this.fileMetadataService.deleteFileById(
fileId,
workspaceId,
);
if (!deletedFile) {
throw new Error(`File with id ${fileId} not found`);
}
return deletedFile;
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
@Injectable()
export class FileAIChatService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile({
file,
filename,
workspaceId,
}: {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDTO> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: name,
mimeType,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
};
}
}
@@ -1,10 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SignedFile')
export class SignedFileDTO {
@Field(() => String)
path: string;
@Field(() => String)
token: string;
}
@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [FileModule, PermissionsModule, SecureHttpClientModule],
providers: [FileUploadService, FileUploadResolver],
exports: [FileUploadService, FileUploadResolver],
})
export class FileUploadModule {}
@@ -1,47 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
jest.mock('graphql-upload/GraphQLUpload.mjs', () => ({
__esModule: true,
default: {},
}));
jest.mock('graphql-upload/processRequest.mjs', () => ({
__esModule: true,
FileUpload: {},
}));
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { FileUploadResolver } from './file-upload.resolver';
describe('FileUploadResolver', () => {
let resolver: FileUploadResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileUploadResolver,
{
provide: FileUploadService,
useValue: {},
},
{
provide: TwentyConfigService,
useValue: {},
},
{
provide: PermissionsService,
useValue: {},
},
],
}).compile();
resolver = module.get<FileUploadResolver>(FileUploadResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});
@@ -1,83 +0,0 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.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';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@MetadataResolver()
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => SignedFileDTO, {
deprecationReason: 'Use uploadFilesFieldFile instead',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { files } = await this.fileUploadService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload file');
}
return files[0];
}
@Mutation(() => SignedFileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadImage(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload image');
}
return files[0];
}
}
@@ -1,214 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import FileType from 'file-type';
import sharp from 'sharp';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { getCropSize, getImageBufferFromUrl } from 'src/utils/image';
export type SignedFile = { path: string; token: string };
export type SignedFilesResult = {
name: string;
mimeType: string | undefined;
files: SignedFile[];
};
@Injectable()
export class FileUploadService {
private readonly logger = new Logger(FileUploadService.name);
constructor(
private readonly fileStorage: FileStorageService,
private readonly fileService: FileService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private async _uploadFile({
file,
filename,
mimeType,
folder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
folder: string;
}) {
await this.fileStorage.writeFileLegacy({
file,
name: filename,
mimeType,
folder,
});
}
/**
* @deprecated Use uploadWorkspaceRecordFile if uploading workspace records-scoped files. Or create your dedicated upload file service.
*/
async uploadFile({
file,
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}): Promise<SignedFilesResult> {
const { ext, name } = buildFileInfo(filename);
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
await this._uploadFile({
file: sanitizeFile({ file, ext, mimeType }),
filename: name,
mimeType,
folder,
});
const signedPayload = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
return {
name,
mimeType,
files: [{ path: `${fileFolder}/${name}`, token: signedPayload }],
};
}
async uploadImageFromUrl({
imageUrl,
fileFolder,
workspaceId,
}: {
imageUrl: string;
fileFolder: FileFolder;
workspaceId: string;
}) {
const imageData = await this.fetchImageBufferFromUrl(imageUrl).catch(
(error) => {
this.logger.warn(
`Failed to fetch image from URL: ${imageUrl}${error instanceof Error ? error.message : String(error)}`,
);
return null;
},
);
if (!imageData) {
return { name: '', mimeType: undefined, files: [] };
}
return await this.uploadImage({
file: imageData.buffer,
filename: `${v4()}.${imageData.extension}`,
mimeType: imageData.mimeType,
fileFolder,
workspaceId,
});
}
private async fetchImageBufferFromUrl(imageUrl: string): Promise<{
buffer: Buffer;
extension: string;
mimeType: string;
} | null> {
const httpClient = this.secureHttpClientService.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
if (!buffer || buffer.length === 0) {
return null;
}
const type = await FileType.fromBuffer(buffer);
if (!type || !type.ext || !type.mime || !type.mime.startsWith('image/')) {
throw new Error(`Invalid image type for URL: ${imageUrl}`);
}
return { buffer, extension: type.ext, mimeType: type.mime };
}
async uploadImage({
file,
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}): Promise<SignedFilesResult> {
const { name } = buildFileInfo(filename);
const cropSizes = settings.storage.imageCropSizes[fileFolder];
if (!cropSizes) {
throw new Error(`No crop sizes found for ${fileFolder}`);
}
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
const images = await Promise.all(
sizes.map((size) =>
sharp(file).resize({
[size?.type || 'width']: size?.value ?? undefined,
}),
),
);
const files: Array<SignedFile> = [];
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
const token = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
files.push({
path: `${fileFolder}/${cropSizes[index]}/${name}`,
token,
});
return this._uploadFile({
file: buffer,
filename: `${cropSizes[index]}/${name}`,
mimeType,
folder,
});
}),
);
return {
name,
mimeType,
files,
};
}
private getWorkspaceFolderName(workspaceId: string, fileFolder: FileFolder) {
return `workspace-${workspaceId}/${fileFolder}`;
}
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileAIChatModule } from 'src/engine/core-modules/file/file-ai-chat/file-ai-chat.module';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
@@ -16,13 +17,10 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
import { FileUploadService } from './file-upload/services/file-upload.service';
import { FileUrlModule } from './file-url/file-url.module';
import { FileWorkflowModule } from './file-workflow/file-workflow.module';
import { FilesFieldModule } from './files-field/files-field.module';
import { FileByIdGuard } from './guards/file-by-id.guard';
import { FileResolver } from './resolvers/file.resolver';
import { FileMetadataService } from './services/file-metadata.service';
import { FileService } from './services/file.service';
@Module({
@@ -35,28 +33,25 @@ import { FileService } from './services/file.service';
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
FileAIChatModule,
SecureHttpClientModule,
],
providers: [
FileService,
FileMetadataService,
FileResolver,
FilePathGuard,
FileByIdGuard,
FileAttachmentListener,
FileWorkspaceMemberListener,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
FileUploadService,
],
exports: [
FileService,
FileMetadataService,
FileUrlModule,
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
FileUploadService,
FileAIChatModule,
],
controllers: [FileController],
})
@@ -11,6 +11,7 @@ export const SUPPORTED_FILE_FOLDERS = [
FileFolder.CorePicture,
FileFolder.FilesField,
FileFolder.Workflow,
FileFolder.AgentChat,
] as const;
export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number];
@@ -1,95 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from './file.service';
@Injectable()
export class FileMetadataService {
constructor(
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileService: FileService,
private readonly fileUploadService: FileUploadService,
) {}
/**
* @deprecated
*/
async createFile({
file,
filename,
mimeType,
workspaceId,
}: {
file: Buffer;
filename: string;
mimeType: string;
workspaceId: string;
}): Promise<FileDTO> {
const { files } = await this.fileUploadService.uploadFile({
file,
filename,
mimeType,
fileFolder: FileFolder.File,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload file');
}
const createdFile = this.fileRepository.create({
path: files[0].path,
size: file.length,
workspaceId,
});
const savedFile = await this.fileRepository.save(createdFile);
return savedFile;
}
/**
* @deprecated
*/
async deleteFileById(
id: string,
workspaceId: string,
): Promise<FileDTO | null> {
const file = await this.fileRepository.findOne({
where: { id, workspaceId },
});
if (!file) {
return null;
}
const { folderPath, filename } = extractFolderPathFilenameAndTypeOrThrow(
file.path,
);
try {
if (file.path) {
await this.fileService.deleteFile({
folderPath,
filename,
workspaceId,
});
}
await this.fileRepository.delete(file.id);
return file;
} catch (error) {
throw new Error(`Failed to delete file ${id}: ${error.message}`);
}
}
}
@@ -15,18 +15,11 @@ jest.mock('uuid', () => ({
describe('FileService', () => {
let service: FileService;
let fileStorageService: FileStorageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{
provide: FileStorageService,
useValue: {
copyLegacy: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {},
@@ -35,6 +28,10 @@ describe('FileService', () => {
provide: JwtWrapperService,
useValue: {},
},
{
provide: FileStorageService,
useValue: {},
},
{
provide: getRepositoryToken(FileEntity),
useValue: {},
@@ -47,35 +44,9 @@ describe('FileService', () => {
}).compile();
service = module.get<FileService>(FileService);
fileStorageService = module.get<FileStorageService>(FileStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('copyFileFromWorkspaceToWorkspace - should copy a file to a new workspace', async () => {
const result = await service.copyFileFromWorkspaceToWorkspace(
'workspaceId',
'path/to/file',
'newWorkspaceId',
);
expect(fileStorageService.copyLegacy).toHaveBeenCalledWith({
from: {
folderPath: 'workspace-workspaceId/path/to',
filename: 'file',
},
to: {
folderPath: 'workspace-newWorkspaceId/path/to',
filename: 'mocked-uuid',
},
});
expect(result).toEqual([
'workspace-newWorkspaceId',
'path/to',
'mocked-uuid',
]);
});
});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { basename, dirname, extname } from 'path';
import { type Readable } from 'stream';
import { isNonEmptyString } from '@sniptt/guards';
@@ -11,7 +10,6 @@ import {
extractFolderPathFilenameAndTypeOrThrow,
} from 'twenty-shared/utils';
import { Like, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
@@ -23,6 +21,7 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class FileService {
@@ -36,18 +35,6 @@ export class FileService {
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async getFileStream(
folderPath: string,
filename: string,
workspaceId: string,
): Promise<Readable> {
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
return await this.fileStorageService.readFileLegacy({
filePath: `${workspaceFolderPath}/${filename}`,
});
}
async getFileStreamByPath({
workspaceId,
applicationId,
@@ -106,6 +93,45 @@ export class FileService {
});
}
async getFileContentById({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<{ buffer: Buffer; mimeType: string }> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId,
},
});
const stream = await this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
const buffer = await streamToBuffer(stream);
return {
buffer,
mimeType: file.mimeType ?? 'application/octet-stream',
};
}
signFileUrl({ url, workspaceId }: { url: string; workspaceId: string }) {
if (!isNonEmptyString(url)) {
return url;
@@ -177,30 +203,4 @@ export class FileService {
folderPath: workspaceFolderPath,
});
}
async copyFileFromWorkspaceToWorkspace(
fromWorkspaceId: string,
fromPath: string,
toWorkspaceId: string,
) {
const subFolder = dirname(fromPath);
const fromWorkspaceFolderPath = `workspace-${fromWorkspaceId}`;
const toWorkspaceFolderPath = `workspace-${toWorkspaceId}`;
const fromFilename = basename(fromPath);
const toFilename = uuidV4() + extname(fromFilename);
await this.fileStorageService.copyLegacy({
from: {
folderPath: `${fromWorkspaceFolderPath}/${subFolder}`,
filename: fromFilename,
},
to: {
folderPath: `${toWorkspaceFolderPath}/${subFolder}`,
filename: toFilename,
},
});
return [toWorkspaceFolderPath, subFolder, toFilename];
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -20,6 +21,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity]),
ApplicationModule,
FeatureFlagModule,
FileModule,
JwtModule,
@@ -6,9 +6,9 @@ export const CodeInterpreterInputZodSchema = z.object({
.array(
z.object({
filename: z.string().describe('Name of the file'),
url: z
fileId: z
.string()
.describe('URL of the file to include (from user attachments)'),
.describe('ID of the uploaded file (from user attachments)'),
}),
)
.optional()
@@ -15,18 +15,23 @@ import {
type OutputFile,
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import {
type AccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { CodeInterpreterInputZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
import { TWENTY_MCP_HELPER } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const';
import { type CodeInterpreterInput } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import {
type CodeInterpreterFileInput,
type CodeInterpreterInput,
} from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
@@ -49,6 +54,8 @@ export class CodeInterpreterTool implements Tool {
private readonly codeInterpreterService: CodeInterpreterService,
private readonly fileStorageService: FileStorageService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly applicationService: ApplicationService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtWrapperService: JwtWrapperService,
@@ -94,7 +101,7 @@ export class CodeInterpreterTool implements Tool {
);
try {
const inputFiles = await this.downloadInputFiles(files);
const inputFiles = await this.downloadInputFiles(files, workspaceId);
this.logger.log(
`Executing code interpreter with ${inputFiles.length} input files`,
@@ -251,74 +258,43 @@ export class CodeInterpreterTool implements Tool {
}
private async downloadInputFiles(
files?: { filename: string; url: string }[],
files?: CodeInterpreterFileInput[],
workspaceId?: string,
): Promise<InputFile[]> {
if (!files || files.length === 0) {
return [];
}
const inputFiles: InputFile[] = [];
const serverUrl = this.twentyConfigService.get('SERVER_URL');
for (const file of files) {
try {
if (file.url.startsWith('data:')) {
const parsed = this.parseDataUrl(file.url);
if (parsed) {
inputFiles.push({
filename: file.filename,
content: parsed.content,
mimeType: parsed.mimeType,
});
}
if (!workspaceId) {
this.logger.warn(
`Cannot resolve file ${file.filename}: workspaceId is required`,
);
continue;
}
// Internal file downloads (from the server itself) use a plain client;
// external URLs go through the SSRF-protected client
const isInternalFileUrl = file.url.startsWith(serverUrl);
const httpClient = isInternalFileUrl
? this.secureHttpClientService.getInternalHttpClient()
: this.secureHttpClientService.getHttpClient();
const response = await httpClient.get(file.url, {
responseType: 'arraybuffer',
timeout: 30_000,
const { buffer, mimeType } = await this.fileService.getFileContentById({
fileId: file.fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
});
inputFiles.push({
filename: file.filename,
content: Buffer.from(response.data),
mimeType:
response.headers['content-type'] ?? 'application/octet-stream',
content: buffer,
mimeType,
});
} catch (error) {
this.logger.warn(`Failed to download file ${file.filename}`, error);
this.logger.warn(`Failed to resolve file ${file.filename}`, error);
}
}
return inputFiles;
}
private parseDataUrl(
dataUrl: string,
): { content: Buffer; mimeType: string } | null {
// Format: data:{mimeType};base64,{base64data}
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return null;
}
const [, mimeType, base64Data] = match;
return {
content: Buffer.from(base64Data, 'base64'),
mimeType,
};
}
private generateSessionToken(
workspaceId: string,
userId?: string,
@@ -349,30 +325,42 @@ export class CodeInterpreterTool implements Tool {
workspaceId: string,
executionId: string,
): Promise<CodeExecutionFile | null> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const sanitizedFilename = path.basename(file.filename);
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fileId = v4();
const resourcePath = `code-interpreter/${executionId}/${fileId}-${sanitizedFilename}`;
const savedFile = await this.fileStorageService.writeFile({
sourceFile: file.content,
mimeType: file.mimeType,
folder,
});
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
resourcePath,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.AgentChat,
});
return {
fileId: savedFile.id,
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
url: signedUrl,
mimeType: file.mimeType,
};
} catch (error) {
@@ -388,12 +376,9 @@ export class CodeInterpreterTool implements Tool {
executionId: string,
alreadyUploadedFiles: CodeExecutionFile[],
): Promise<CodeExecutionFile[]> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const outputFileUrls: CodeExecutionFile[] = [...alreadyUploadedFiles];
const uploadedFilenames = new Set(
alreadyUploadedFiles.map((f) => f.filename),
alreadyUploadedFiles.map((uploadedFile) => uploadedFile.filename),
);
for (const file of files) {
@@ -403,32 +388,14 @@ export class CodeInterpreterTool implements Tool {
continue;
}
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
mimeType: file.mimeType,
folder,
});
const uploadedFile = await this.uploadSingleFile(
file,
workspaceId,
executionId,
);
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
workspaceId,
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
outputFileUrls.push({
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
mimeType: file.mimeType,
});
} catch (error) {
this.logger.warn(
`Failed to upload output file ${file.filename}`,
error,
);
if (uploadedFile) {
outputFileUrls.push(uploadedFile);
}
}
@@ -1,6 +1,6 @@
export type CodeInterpreterFileInput = {
filename: string;
url: string;
fileId: string;
};
export type CodeInterpreterInput = {
@@ -1,32 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
isValidUuid,
} from 'twenty-shared/utils';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { WorkflowAttachment } from 'twenty-shared/workflow';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import {
EmailToolException,
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@@ -34,7 +27,6 @@ import { MessagingAccountAuthenticationService } from 'src/modules/messaging/mes
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class EmailComposerService {
private readonly logger = new Logger(EmailComposerService.name);
@@ -45,16 +37,8 @@ export class EmailComposerService {
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isOtherFileMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
}
private async getConnectedAccount(
connectedAccountId: string,
workspaceId: string,
@@ -214,29 +198,11 @@ export class EmailComposerService {
const attachments: MessageAttachment[] = [];
for (const fileMetadata of files) {
const fileEntity = fileEntityMap.get(fileMetadata.id)!;
const { folderPath, filename } = extractFolderPathFilenameAndTypeOrThrow(
fileEntity.path,
);
const isOtherFileMigrated = await this.isOtherFileMigrated(workspaceId);
let stream: Readable;
if (isOtherFileMigrated) {
stream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
});
} else {
stream = await this.fileService.getFileStream(
folderPath,
filename,
workspaceId,
);
}
const stream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
});
const buffer = await streamToBuffer(stream);
@@ -8,12 +8,10 @@ import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-acc
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
@@ -21,8 +19,8 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@@ -47,7 +45,6 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
WorkspaceDomainsModule,
TwentyORMModule,
UserRoleModule,
FileUploadModule,
FileModule,
TokenModule,
PermissionsModule,
@@ -58,10 +55,6 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
}),
],
exports: [UserWorkspaceService],
providers: [
UserWorkspaceService,
UserWorkspaceResolver,
UploadProfilePicturePermissionGuard,
],
providers: [UserWorkspaceService, UploadProfilePicturePermissionGuard],
})
export class UserWorkspaceModule {}
@@ -1,47 +0,0 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@MetadataResolver()
export class UserWorkspaceResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => SignedFileDTO)
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
async uploadWorkspaceMemberProfilePictureLegacy(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.ProfilePicture;
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload profile picture');
}
return files[0];
}
}
@@ -1,7 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { type DataSource, type Repository } from 'typeorm';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
@@ -12,10 +11,6 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
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 { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import {
FileUploadService,
type SignedFilesResult,
} from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -40,8 +35,6 @@ describe('UserWorkspaceService', () => {
let approvedAccessDomainService: ApprovedAccessDomainService;
let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
let userRoleService: UserRoleService;
let fileService: FileService;
let fileUploadService: FileUploadService;
let onboardingService: OnboardingService;
beforeEach(async () => {
@@ -131,6 +124,10 @@ describe('UserWorkspaceService', () => {
provide: FileCorePictureService,
useValue: {},
},
{
provide: FileService,
useValue: {},
},
{
provide: FileStorageService,
useValue: {
@@ -141,18 +138,6 @@ describe('UserWorkspaceService', () => {
provide: LoginTokenService,
useValue: {},
},
{
provide: FileUploadService,
useValue: {
uploadImageFromUrl: jest.fn(),
},
},
{
provide: FileService,
useValue: {
copyFileFromWorkspaceToWorkspace: jest.fn(),
},
},
{
provide: OnboardingService,
useValue: {
@@ -169,7 +154,6 @@ describe('UserWorkspaceService', () => {
}).compile();
service = module.get<UserWorkspaceService>(UserWorkspaceService);
fileService = module.get<FileService>(FileService);
userWorkspaceRepository = module.get(
getRepositoryToken(UserWorkspaceEntity),
);
@@ -189,7 +173,6 @@ describe('UserWorkspaceService', () => {
} as unknown as WorkspaceRepository<UserWorkspaceEntity>);
userRoleService = module.get<UserRoleService>(UserRoleService);
fileUploadService = module.get<FileUploadService>(FileUploadService);
onboardingService = module.get<OnboardingService>(OnboardingService);
});
@@ -198,42 +181,6 @@ describe('UserWorkspaceService', () => {
});
describe('create', () => {
it("should create a user workspace with a default avatar url if it's an existing user with a user workspace having a default avatar url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue({
defaultAvatarUrl: 'path/to/file',
} as UserWorkspaceEntity);
jest
.spyOn(fileService, 'copyFileFromWorkspaceToWorkspace')
.mockResolvedValue(['', 'path/to', 'copy']);
const result = await service.create({
userId,
workspaceId,
isExistingUser: true,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: 'path/to/copy',
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's an existing user without any user workspace having a default avatar url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -265,89 +212,6 @@ describe('UserWorkspaceService', () => {
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace with a default avatar url if it's a new user with a picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest.spyOn(fileUploadService, 'uploadImageFromUrl').mockResolvedValue({
files: [{ path: 'path/to/file', token: 'token' }],
} as SignedFilesResult);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: 'picture-url',
});
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledWith({
imageUrl: 'picture-url',
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: 'path/to/file',
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it('should create a user workspace without a default avatar url if image fetch fails', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest
.spyOn(fileUploadService, 'uploadImageFromUrl')
.mockRejectedValue(
new Error(
'Failed to fetch image from https://lh3.googleusercontent.com/a/invalid: Request failed with status code 404',
),
);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: 'https://lh3.googleusercontent.com/a/invalid',
});
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledTimes(1);
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledWith({
imageUrl: 'https://lh3.googleusercontent.com/a/invalid',
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: undefined,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's a new user without a picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -373,45 +237,6 @@ describe('UserWorkspaceService', () => {
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's a new user with an empty picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as unknown as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
const uploadImageFromUrlSpy = jest
.spyOn(fileUploadService, 'uploadImageFromUrl')
.mockResolvedValue({
files: [{ path: 'path/to/file', token: 'token' }],
} as SignedFilesResult);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: '',
});
expect(uploadImageFromUrlSpy).not.toHaveBeenCalled();
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: undefined,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
});
describe('createWorkspaceMember', () => {
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm';
@@ -18,9 +18,7 @@ import {
import { type AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@@ -62,10 +60,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly userRoleService: UserRoleService,
private readonly fileCorePictureService: FileCorePictureService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly onboardingService: OnboardingService,
private readonly featureFlagService: FeatureFlagService,
) {
super(userWorkspaceRepository);
}
@@ -423,89 +419,16 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
applicationUniversalIdentifier?: string,
queryRunner?: QueryRunner,
) {
const isOtherFileMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
if (isOtherFileMigrated) {
return this.computeDefaultAvatarUrlMigrated(
userId,
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
}
return this.computeDefaultAvatarUrlLegacy(
return this.computeDefaultAvatarUrlMigrated(
userId,
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
}
private async computeDefaultAvatarUrlLegacy(
userId: string,
workspaceId: string,
isExistingUser: boolean,
pictureUrl?: string,
) {
if (isExistingUser) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId,
defaultAvatarUrl: Not(IsNull()),
},
order: {
createdAt: 'ASC',
},
});
if (!isDefined(userWorkspace?.defaultAvatarUrl)) return;
try {
const [_, subFolder, filename] =
await this.fileService.copyFileFromWorkspaceToWorkspace(
userWorkspace.workspaceId,
userWorkspace.defaultAvatarUrl,
workspaceId,
);
return `${subFolder}/${filename}`;
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
return;
}
throw error;
}
}
if (!isDefined(pictureUrl) || pictureUrl === '') return;
try {
const { files } = await this.fileUploadService.uploadImageFromUrl({
imageUrl: pictureUrl,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
if (!files.length) {
return;
}
return files[0].path;
} catch (error) {
this.logger.warn(
`Failed to upload profile picture from URL: ${pictureUrl}${error instanceof Error ? error.message : String(error)}`,
);
return;
}
}
private async computeDefaultAvatarUrlMigrated(
userId: string,
workspaceId: string,
@@ -9,12 +9,12 @@ import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
import { WorkspaceFlatWorkspaceMemberMapCacheService } from 'src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service';
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
@@ -25,7 +25,6 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@@ -44,7 +43,6 @@ import { UserService } from './services/user.service';
}),
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
DataSourceModule,
FileUploadModule,
WorkspaceModule,
OnboardingModule,
TypeOrmModule.forFeature([KeyValuePairEntity, UserWorkspaceEntity]),
@@ -6,7 +6,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
@@ -16,8 +15,8 @@ import { CustomDomainManagerModule } from 'src/engine/core-modules/domain/custom
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -26,9 +25,9 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -52,7 +51,6 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
BillingModule,
FileModule,
TokenModule,
FileUploadModule,
NestjsQueryTypeOrmModule.forFeature([
UserEntity,
WorkspaceEntity,
@@ -9,13 +9,10 @@ import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import assert from 'assert';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
@@ -30,8 +27,6 @@ import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custo
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -73,7 +68,6 @@ import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { getRequest } from 'src/utils/extract-request';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
@@ -94,7 +88,6 @@ export class WorkspaceResolver {
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly billingSubscriptionService: BillingSubscriptionService,
@@ -155,39 +148,6 @@ export class WorkspaceResolver {
return 'auto';
}
@Mutation(() => SignedFileDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
async uploadWorkspaceLogoLegacy(
@AuthWorkspace() { id }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.WorkspaceLogo;
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId: id,
});
if (!files.length) {
throw new Error('Failed to upload workspace logo');
}
await this.workspaceService.updateOne(id, {
logo: files[0].path,
});
return files[0];
}
@ResolveField(() => [FeatureFlagDTO], { nullable: true })
async featureFlags(
@Parent() workspace: WorkspaceEntity,
@@ -330,35 +290,15 @@ export class WorkspaceResolver {
@ResolveField(() => String)
async logo(@Parent() workspace: WorkspaceEntity): Promise<string> {
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspace.id,
)
) {
if (!isDefined(workspace.logoFileId)) {
return '';
}
return this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
});
if (!isDefined(workspace.logoFileId)) {
return '';
}
if (workspace.logo) {
try {
return this.fileService.signFileUrl({
url: workspace.logo,
workspaceId: workspace.id,
});
} catch {
return workspace.logo;
}
}
return workspace.logo ?? '';
return this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
});
}
@ResolveField(() => [BillingEntitlementDTO])
@@ -2,13 +2,14 @@ import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -17,6 +18,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
import { AgentMessagePartEntity } from './entities/agent-message-part.entity';
import { AgentMessageEntity } from './entities/agent-message.entity';
import { AgentTurnEntity } from './entities/agent-turn.entity';
import { AgentMessagePartResolver } from './resolvers/agent-message-part.resolver';
import { AgentActorContextService } from './services/agent-actor-context.service';
import { AgentAsyncExecutorService } from './services/agent-async-executor.service';
@@ -25,6 +27,7 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
AiBillingModule,
AiModelsModule,
AiAgentModule,
FileUrlModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
UserRoleModule,
@@ -40,7 +43,11 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
WorkspaceEntity,
]),
],
providers: [AgentAsyncExecutorService, AgentActorContextService],
providers: [
AgentAsyncExecutorService,
AgentActorContextService,
AgentMessagePartResolver,
],
exports: [
AgentAsyncExecutorService,
AgentActorContextService,
@@ -73,6 +73,9 @@ export class AgentMessagePartDTO {
@Field(() => String, { nullable: true })
fileFilename: string | null;
@Field(() => UUIDScalarType, { nullable: true })
fileId: string | null;
@Field(() => String, { nullable: true })
fileUrl: string | null;
@@ -10,6 +10,7 @@ import {
Relation,
} from 'typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
@Entity('agentMessagePart')
@@ -81,14 +82,18 @@ export class AgentMessagePartEntity {
@Column({ type: 'varchar', nullable: true })
sourceDocumentFilename: string | null;
@Column({ type: 'varchar', nullable: true })
fileMediaType: string | null;
@Column({ type: 'varchar', nullable: true })
fileFilename: string | null;
@Column({ type: 'varchar', nullable: true })
fileUrl: string | null;
@Column({ type: 'uuid', nullable: true })
fileId: string | null;
@ManyToOne(() => FileEntity, {
onDelete: 'RESTRICT',
nullable: true,
})
@JoinColumn({ name: 'fileId' })
file: Relation<FileEntity> | null;
@Column({ type: 'jsonb', nullable: true })
providerMetadata: Record<string, Record<string, JSONValue>> | null;

Some files were not shown because too many files have changed in this diff Show More