feat(front): upload files directly to storage for files-field, attachments and workflow (#22576)
## Context Final step of the direct-to-storage upload work (follows #22449 endpoints, #22531 reaper, #22533 content-verify). The server can now hand the client an upload URL so bytes go straight to storage instead of being buffered through the Node process (the original OOM problem). This PR switches the frontend to that flow for the three in-scope surfaces. ## What this does Adds **`useDirectFileUpload`** — the shared hook that runs the handshake: 1. `createFileUpload({ filename, size, fileFolder, fieldMetadataId? })` → `{ fileId, uploadUrl, contentType, expiresAt }` 2. `PUT` the raw file to `uploadUrl` with `Content-Type: contentType` 3. `completeFileUpload({ fileId })` → `FileWithSignedUrl` (`{ id, path, size, createdAt, url }`) Routes the three existing upload hooks through it, **keeping each hook's public signature and return shape unchanged** so no call sites change: | Hook | Folder | |---|---| | `useUploadFilesFieldFile` (FILES fields) | `FilesField` | | `useUploadAttachmentFile` (attachments — the Attachment object's `file` FILES field) | `FilesField` | | `useUploadWorkflowFile` (workflow send-email attachments) | `Workflow` | Adds the `CreateFileUpload` / `CompleteFileUpload` gql documents and regenerates `generated-metadata` types (+19 lines, scoped to the two new operations). ## Out of scope - AI-chat (`AgentChat`) and email-attachment (`EmailAttachment`) uploads keep the legacy buffered mutations — those folders aren't in the server's direct-upload allowlist (`[FilesField, Workflow]`). - Workflow serverless-function code is saved via metadata mutations, not the file path. ## Notes - The legacy `uploadFilesFieldFile` / `uploadWorkflowFile` mutations still exist server-side and remain used by the out-of-scope surfaces, so this is non-breaking. - Local storage routes the `PUT` to the token-authenticated streaming endpoint (`SERVER_URL/file-upload/:id?token=…`); S3 uses a presigned `PUT`. CORS is already enabled globally on the server and the token rides in the query string (no cookies), so the browser upload works cross-origin. ## Verification `typecheck` and `lint:diff-with-main` green on `twenty-front`; codegen ran against a live metadata schema so the generated file matches the drift check. No existing tests/stories cover these hooks. 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/22576?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:
File diff suppressed because one or more lines are too long
+6
-13
@@ -1,22 +1,16 @@
|
||||
import { type Attachment } from '@/activities/files/types/Attachment';
|
||||
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
|
||||
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
UploadFilesFieldFileDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { FieldMetadataType, FileFolder } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useUploadAttachmentFile = () => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const { uploadFile: directUploadFile } = useDirectFileUpload();
|
||||
const { objectMetadataItem: attachmentMetadata } = useObjectMetadataItem({
|
||||
objectNameSingular: CoreObjectNameSingular.Attachment,
|
||||
});
|
||||
@@ -40,12 +34,11 @@ export const useUploadAttachmentFile = () => {
|
||||
new Error(t`File field not found for attachment object`),
|
||||
);
|
||||
|
||||
const result = await uploadFilesFieldFile({
|
||||
variables: { file, fieldMetadataId: filesFieldMetadataId },
|
||||
const uploadedFile = await directUploadFile(file, {
|
||||
fileFolder: FileFolder.FilesField,
|
||||
fieldMetadataId: filesFieldMetadataId,
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadFilesFieldFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error("Couldn't upload the attachment.");
|
||||
}
|
||||
|
||||
+6
-13
@@ -1,18 +1,15 @@
|
||||
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/maxAttachmentSize';
|
||||
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
|
||||
import { formatFileSize } from '@/file/utils/formatFileSize';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
extractFolderPathFilenameAndTypeOrThrow,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils';
|
||||
import { type WorkflowAttachment } from 'twenty-shared/workflow';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { UploadWorkflowFileDocument } from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated-metadata/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export const useUploadWorkflowFile = () => {
|
||||
const [uploadWorkflowFileMutation] = useMutation(UploadWorkflowFileDocument);
|
||||
const { uploadFile: directUploadFile } = useDirectFileUpload();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const uploadWorkflowFile = async (
|
||||
@@ -28,13 +25,9 @@ export const useUploadWorkflowFile = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await uploadWorkflowFileMutation({
|
||||
variables: { file },
|
||||
const uploadedFile = await directUploadFile(file, {
|
||||
fileFolder: FileFolder.Workflow,
|
||||
});
|
||||
const uploadedFile = result?.data?.uploadWorkflowFile;
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
const workflowFile: WorkflowAttachment = {
|
||||
id: uploadedFile.id,
|
||||
name: file.name,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const COMPLETE_FILE_UPLOAD = gql`
|
||||
mutation CompleteFileUpload($fileId: String!) {
|
||||
completeFileUpload(fileId: $fileId) {
|
||||
id
|
||||
path
|
||||
size
|
||||
createdAt
|
||||
url
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_FILE_UPLOAD = gql`
|
||||
mutation CreateFileUpload(
|
||||
$filename: String!
|
||||
$size: Float!
|
||||
$fileFolder: FileFolder!
|
||||
$fieldMetadataId: String
|
||||
) {
|
||||
createFileUpload(
|
||||
filename: $filename
|
||||
size: $size
|
||||
fileFolder: $fileFolder
|
||||
fieldMetadataId: $fieldMetadataId
|
||||
) {
|
||||
fileId
|
||||
uploadUrl
|
||||
contentType
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CompleteFileUploadDocument,
|
||||
CreateFileUploadDocument,
|
||||
type FileFolder,
|
||||
type FileWithSignedUrl,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type DirectFileUploadOptions = {
|
||||
fileFolder: FileFolder;
|
||||
fieldMetadataId?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export const useDirectFileUpload = () => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [createFileUpload] = useMutation(CreateFileUploadDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const [completeFileUpload] = useMutation(CompleteFileUploadDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
{ fileFolder, fieldMetadataId, signal }: DirectFileUploadOptions,
|
||||
): Promise<FileWithSignedUrl> => {
|
||||
const createResult = await createFileUpload({
|
||||
variables: {
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
fileFolder,
|
||||
fieldMetadataId,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadTarget = createResult?.data?.createFileUpload;
|
||||
|
||||
if (!isDefined(uploadTarget)) {
|
||||
throw new Error('Failed to initiate file upload');
|
||||
}
|
||||
|
||||
const putResponse = await fetch(uploadTarget.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': uploadTarget.contentType },
|
||||
body: file,
|
||||
credentials: 'omit',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!putResponse.ok) {
|
||||
throw new Error(`File upload failed with status ${putResponse.status}`);
|
||||
}
|
||||
|
||||
const completeResult = await completeFileUpload({
|
||||
variables: { fileId: uploadTarget.fileId },
|
||||
});
|
||||
|
||||
const uploadedFile = completeResult?.data?.completeFileUpload;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error('Failed to finalize file upload');
|
||||
}
|
||||
|
||||
return uploadedFile;
|
||||
};
|
||||
|
||||
return { uploadFile };
|
||||
};
|
||||
+6
-15
@@ -1,32 +1,23 @@
|
||||
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { UploadFilesFieldFileDocument } from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated-metadata/graphql';
|
||||
|
||||
const DEFAULT_VALUE_BEFORE_SERVER_RESPONSE =
|
||||
'default-value-before-server-response';
|
||||
|
||||
export const useUploadFilesFieldFile = () => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const { uploadFile: directUploadFile } = useDirectFileUpload();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
const uploadFile = async (file: File, fieldMetadataId: string) => {
|
||||
try {
|
||||
const result = await uploadFilesFieldFile({
|
||||
variables: { file, fieldMetadataId },
|
||||
const uploadedFile = await directUploadFile(file, {
|
||||
fileFolder: FileFolder.FilesField,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadFilesFieldFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error(t`File upload failed`);
|
||||
}
|
||||
|
||||
const fileName = file.name;
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`File "${fileName}" uploaded successfully`,
|
||||
|
||||
Reference in New Issue
Block a user