From 0706c7c1bcfb888ec9162cc664361b1cda576052 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 6 Jul 2026 15:28:57 +0200 Subject: [PATCH] feat(front): upload files directly to storage for files-field, attachments and workflow (#22576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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)_ Review in cubic --- .../src/generated-metadata/graphql.ts | 19 +++++ .../files/hooks/useUploadAttachmentFile.tsx | 19 ++--- .../hooks/useUploadWorkflowFile.ts | 19 ++--- .../graphql/mutations/completeFileUpload.ts | 13 ++++ .../graphql/mutations/createFileUpload.ts | 22 ++++++ .../modules/file/hooks/useDirectFileUpload.ts | 70 +++++++++++++++++++ .../hooks/useUploadFilesFieldFile.ts | 21 ++---- 7 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 packages/twenty-front/src/modules/file/graphql/mutations/completeFileUpload.ts create mode 100644 packages/twenty-front/src/modules/file/graphql/mutations/createFileUpload.ts create mode 100644 packages/twenty-front/src/modules/file/hooks/useDirectFileUpload.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a8ba1d5b1b..7316c2184c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -7106,6 +7106,23 @@ export type FindOnePageLayoutTypeQueryVariables = Exact<{ export type FindOnePageLayoutTypeQuery = { __typename?: 'Query', getPageLayout?: { __typename?: 'PageLayout', id: string, type: PageLayoutType } | null }; +export type CompleteFileUploadMutationVariables = Exact<{ + fileId: Scalars['String']['input']; +}>; + + +export type CompleteFileUploadMutation = { __typename?: 'Mutation', completeFileUpload: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } }; + +export type CreateFileUploadMutationVariables = Exact<{ + filename: Scalars['String']['input']; + size: Scalars['Float']['input']; + fileFolder: FileFolder; + fieldMetadataId?: InputMaybe; +}>; + + +export type CreateFileUploadMutation = { __typename?: 'Mutation', createFileUpload: { __typename?: 'FileUploadTarget', fileId: string, uploadUrl: string, contentType: string, expiresAt: string } }; + export type UploadEmailAttachmentFileMutationVariables = Exact<{ file: Scalars['Upload']['input']; }>; @@ -8889,6 +8906,8 @@ export const FindManyCommandMenuItemsDocument = {"kind":"Document","definitions" export const DuplicateDashboardDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DuplicateDashboard"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplicateDashboard"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const FindOnePageLayoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOnePageLayout"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPageLayout"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutWidgetFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidget"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"gridPosition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetGridPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetVerticalListPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetCanvasPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"isStacked"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"groupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"groupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"dateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"orderBy"}},{"kind":"Field","name":{"kind":"Name","value":"manualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"hideEmptyCategory"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AggregateChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"prefix"}},{"kind":"Field","name":{"kind":"Name","value":"suffix"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}},{"kind":"Field","name":{"kind":"Name","value":"ratioAggregateConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"optionValue"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IframeConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StandaloneRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"body"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blocknote"}},{"kind":"Field","name":{"kind":"Name","value":"markdown"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CalendarConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailThreadConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"fieldDisplayMode"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"newFieldDefaultVisibility"}},{"kind":"Field","name":{"kind":"Name","value":"shouldAllowUserToSeeHiddenFields"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FilesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TasksConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RecordTableConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowRunConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FrontComponentConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutTabId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutTabFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutTab"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"widgets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutWidgetFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayout"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"defaultTabToFocusOnMobileAndSidePanelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tabs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutTabFragment"}}]}}]}}]} as unknown as DocumentNode; export const FindOnePageLayoutTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOnePageLayoutType"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPageLayout"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]} as unknown as DocumentNode; +export const CompleteFileUploadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CompleteFileUpload"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completeFileUpload"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fileId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const CreateFileUploadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateFileUpload"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filename"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"size"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileFolder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FileFolder"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createFileUpload"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filename"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filename"}}},{"kind":"Argument","name":{"kind":"Name","value":"size"},"value":{"kind":"Variable","name":{"kind":"Name","value":"size"}}},{"kind":"Argument","name":{"kind":"Name","value":"fileFolder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileFolder"}}},{"kind":"Argument","name":{"kind":"Name","value":"fieldMetadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"uploadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"contentType"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; export const UploadEmailAttachmentFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadEmailAttachmentFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadEmailAttachmentFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const UploadFilesFieldFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadFilesFieldFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadFilesFieldFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}},{"kind":"Argument","name":{"kind":"Name","value":"fieldMetadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const UploadWorkflowFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkflowFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkflowFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx index 8240510c55..1bd90532a3 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx @@ -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."); } diff --git a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts index 930b474e2b..cbb00da0c5 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts @@ -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, diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/completeFileUpload.ts b/packages/twenty-front/src/modules/file/graphql/mutations/completeFileUpload.ts new file mode 100644 index 0000000000..871125612d --- /dev/null +++ b/packages/twenty-front/src/modules/file/graphql/mutations/completeFileUpload.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/createFileUpload.ts b/packages/twenty-front/src/modules/file/graphql/mutations/createFileUpload.ts new file mode 100644 index 0000000000..8ad5b855a4 --- /dev/null +++ b/packages/twenty-front/src/modules/file/graphql/mutations/createFileUpload.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/file/hooks/useDirectFileUpload.ts b/packages/twenty-front/src/modules/file/hooks/useDirectFileUpload.ts new file mode 100644 index 0000000000..0a7d056a7b --- /dev/null +++ b/packages/twenty-front/src/modules/file/hooks/useDirectFileUpload.ts @@ -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 => { + 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 }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts index 9eebff90a7..f64ecadae6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts @@ -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`,