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:
martmull
2026-07-06 15:28:57 +02:00
committed by GitHub
parent 9657c59272
commit 0706c7c1bc
7 changed files with 142 additions and 41 deletions
File diff suppressed because one or more lines are too long
@@ -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.");
}
@@ -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 };
};
@@ -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`,