feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)
## Context Follow-up to the direct-to-storage upload work (#22449 / #22531 / #22533 / #22576). That migrated files-field, attachments and workflow uploads off the buffered path. This PR does the same for the **last two user-facing upload surfaces**: email attachments and AI-chat files. ## What this does - Adds `EmailAttachment` and `AgentChat` to the server's `DIRECT_UPLOAD_FILE_FOLDERS` allowlist. Both folders already resolve through the workspace-custom-application path in `resolveUploadLocation`, so no other server change is needed. - Routes the two frontend hooks through the existing `useDirectFileUpload` handshake (`createFileUpload` → `PUT` → `completeFileUpload`): - `useUploadEmailAttachment` → `FileFolder.EmailAttachment` (keeps its existing `MAX_ATTACHMENT_SIZE` client check — email has a real send-size limit). - `useAiChatFileUpload` → `FileFolder.AgentChat`. Each hook keeps its public signature and return shape, so call sites are unchanged. No schema change and no codegen needed — the `CreateFileUpload`/`CompleteFileUpload` documents and the `FileFolder` enum values already exist in `generated-metadata` from #22576. ## Why these are safe to migrate Both server services (`file-ai-chat`, `file-email-attachment`) just `writeFile` (store) and return a signed URL — no synchronous processing of the bytes at upload time — so the store-and-reference direct-upload flow fits exactly, same as files-field/workflow. ## Out of scope `CorePicture` (avatars, member/workspace pictures, logos) stays on the buffered path on purpose: small images that go through server-side image handling and are served inline, where the 10 MB body limit is already appropriate. ## Tests Extends the `FileUploadService` unit spec with an `it.each` asserting `createFileUpload` supports the `EmailAttachment` and `AgentChat` folders. ## Verification `typecheck` and `lint:diff-with-main` green on both `twenty-front` and `twenty-server`. (The server jest suite couldn't run in my local sandbox due to an unrelated config-import quirk present on a clean `main` checkout too — CI runs it normally.) https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d --- _Generated by [Claude Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22610?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+5
-19
@@ -1,23 +1,15 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/maxAttachmentSize';
|
||||
import { UPLOAD_EMAIL_ATTACHMENT_FILE } from '@/file/graphql/mutations/uploadEmailAttachmentFile';
|
||||
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
|
||||
import { formatFileSize } from '@/file/utils/formatFileSize';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
type UploadEmailAttachmentFileMutation,
|
||||
type UploadEmailAttachmentFileMutationVariables,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated-metadata/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export const useUploadEmailAttachment = () => {
|
||||
const [uploadEmailAttachmentFileMutation] = useMutation<
|
||||
UploadEmailAttachmentFileMutation,
|
||||
UploadEmailAttachmentFileMutationVariables
|
||||
>(UPLOAD_EMAIL_ATTACHMENT_FILE);
|
||||
const { uploadFile: directUploadFile } = useDirectFileUpload();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -36,16 +28,10 @@ export const useUploadEmailAttachment = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await uploadEmailAttachmentFileMutation({
|
||||
variables: { file },
|
||||
const uploadedFile = await directUploadFile(file, {
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadEmailAttachmentFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
|
||||
const attachment: EmailAttachment = {
|
||||
id: uploadedFile.id,
|
||||
name: file.name,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type AgentChatFileUIPart } from '@/ai/types/agent-chat-file-ui-part.type';
|
||||
import { UploadAiChatFileDocument } from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useAiChatFileUpload = () => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [uploadAiChatFile] = useMutation(UploadAiChatFileDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const { uploadFile: directUploadFile } = useDirectFileUpload();
|
||||
const { t } = useLingui();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useAtomState(
|
||||
@@ -25,26 +22,18 @@ export const useAiChatFileUpload = () => {
|
||||
|
||||
const sendFile = async (file: File): Promise<AgentChatFileUIPart | null> => {
|
||||
try {
|
||||
const result = await uploadAiChatFile({
|
||||
variables: {
|
||||
file,
|
||||
},
|
||||
const uploadedFile = await directUploadFile(file, {
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
});
|
||||
|
||||
const response = result?.data?.uploadAiChatFile;
|
||||
|
||||
if (!isDefined(response)) {
|
||||
throw new Error(t`Couldn't upload the file.`);
|
||||
}
|
||||
|
||||
setAgentChatSelectedFiles(
|
||||
agentChatSelectedFiles.filter((f) => f.name !== file.name),
|
||||
);
|
||||
return {
|
||||
filename: file.name,
|
||||
mediaType: file.type,
|
||||
url: response.url,
|
||||
fileId: response.id,
|
||||
url: uploadedFile.url,
|
||||
fileId: uploadedFile.id,
|
||||
type: 'file',
|
||||
};
|
||||
} catch {
|
||||
|
||||
+24
@@ -189,6 +189,30 @@ describe('FileUploadService', () => {
|
||||
);
|
||||
expect(result.contentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it.each([FileFolder.EmailAttachment, FileFolder.AgentChat])(
|
||||
'should support direct upload for the %s folder',
|
||||
async (fileFolder) => {
|
||||
fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce(
|
||||
'https://bucket/presigned-put',
|
||||
);
|
||||
|
||||
const result = await service.createFileUpload({
|
||||
workspaceId: 'workspace-id',
|
||||
filename: 'document.pdf',
|
||||
size: 1024,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
expect(fileStorageService.createPendingFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileFolder,
|
||||
resourcePath: 'mocked-file-id.pdf',
|
||||
}),
|
||||
);
|
||||
expect(result.uploadUrl).toBe('https://bucket/presigned-put');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('completeFileUpload', () => {
|
||||
|
||||
+2
@@ -41,6 +41,8 @@ import { readReadablePrefix } from 'src/utils/read-readable-prefix';
|
||||
export const DIRECT_UPLOAD_FILE_FOLDERS = [
|
||||
FileFolder.FilesField,
|
||||
FileFolder.Workflow,
|
||||
FileFolder.EmailAttachment,
|
||||
FileFolder.AgentChat,
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
||||
Reference in New Issue
Block a user