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:
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+19
-59
@@ -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);
|
||||
};
|
||||
+3
-6
@@ -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
-6
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-6
@@ -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']);
|
||||
});
|
||||
|
||||
+1
-3
@@ -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);
|
||||
|
||||
+5
-6
@@ -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 });
|
||||
|
||||
+3
-6
@@ -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);
|
||||
|
||||
+5
-6
@@ -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;
|
||||
};
|
||||
|
||||
+14
-46
@@ -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',
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+1
-10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
-7
@@ -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(
|
||||
|
||||
-2
@@ -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 (
|
||||
|
||||
+3
-3
@@ -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,
|
||||
},
|
||||
|
||||
+2
-5
@@ -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,
|
||||
|
||||
+1
-7
@@ -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,
|
||||
|
||||
+6
-29
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+6
-1
@@ -51,7 +51,12 @@ const mocks: MockedResponse[] = [
|
||||
edges {
|
||||
node {
|
||||
__typename
|
||||
avatarUrl
|
||||
avatarFile {
|
||||
fileId
|
||||
label
|
||||
extension
|
||||
url
|
||||
}
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
|
||||
+2
-11
@@ -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);
|
||||
|
||||
|
||||
+1
-9
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+23
-55
@@ -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 };
|
||||
|
||||
-3
@@ -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,
|
||||
|
||||
-10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+20
-54
@@ -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,
|
||||
|
||||
+11
-33
@@ -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 () => {
|
||||
|
||||
+3
-32
@@ -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}
|
||||
/>
|
||||
|
||||
-10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
-531
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-3
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+14
-26
@@ -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,
|
||||
|
||||
+14
-23
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+14
-23
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+16
-18
@@ -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
-62
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-12
@@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
+2
-63
@@ -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 = [
|
||||
|
||||
+11
-61
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+12
-35
@@ -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,
|
||||
|
||||
-77
@@ -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
-76
@@ -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> {
|
||||
|
||||
-49
@@ -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(
|
||||
|
||||
+14
@@ -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 {}
|
||||
+11
-34
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
-10
@@ -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 {}
|
||||
-47
@@ -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();
|
||||
});
|
||||
});
|
||||
-83
@@ -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];
|
||||
}
|
||||
}
|
||||
-214
@@ -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,
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+56
-89
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
export type CodeInterpreterFileInput = {
|
||||
filename: string;
|
||||
url: string;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type CodeInterpreterInput = {
|
||||
|
||||
+10
-44
@@ -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);
|
||||
|
||||
|
||||
+2
-9
@@ -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 {}
|
||||
|
||||
-47
@@ -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];
|
||||
}
|
||||
}
|
||||
+4
-179
@@ -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', () => {
|
||||
|
||||
+4
-81
@@ -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])
|
||||
|
||||
+9
-2
@@ -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,
|
||||
|
||||
+3
@@ -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
-5
@@ -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
Reference in New Issue
Block a user