From ba59ab809401b68468c576953d0f17687eaddfff Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 20 Feb 2026 18:11:59 +0100 Subject: [PATCH] Add upload file in twenty client (#18129) This function ```typescript import { defineLogicFunction } from 'twenty-sdk'; import TwentyClient from 'twenty-sdk/generated'; import { v4 } from 'uuid'; export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '1312be5c-4fd6-44b3-afd0-567352616c7c'; const handler = async (): Promise => { const client = new TwentyClient(); const seedResult = await client.mutation({ createCompany: { id: true, name: true, __args: { data: { name: `toto + ${v4()}`, }, }, }, }); const companyFieldUniversalIdentifier = '20202020-15db-460e-8166-c7b5d87ad4be'; const uploadFileResult = await client.uploadFile( Buffer.from('hello world', 'utf-8'), 'test.txt', undefined, companyFieldUniversalIdentifier, ); await client.mutation({ createAttachment: { id: true, __args: { data: { file: [{ fileId: uploadFileResult.id, label: 'hello-world.txt' }], fullPath: uploadFileResult.path, targetCompanyId: seedResult.createCompany?.id, }, }, }, }); return; }; export default defineLogicFunction({ universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, name: 'post-install', description: 'Add a description for your logic function', timeoutSeconds: 30, handler, }); ``` produces this record below image --- .../developers/extend/capabilities/apps.mdx | 45 +++++++++++++++ .../src/generated-metadata/graphql.ts | 7 +++ .../cli/utilities/client/client-service.ts | 55 ++++++++++++++++++- .../file/files-field/files-field.exception.ts | 1 + .../resolvers/files-field.resolver.ts | 25 +++++++++ .../services/files-field.service.ts | 19 ++++++- 6 files changed, 148 insertions(+), 4 deletions(-) diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index faad8edc8d..be767e48c1 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -793,6 +793,51 @@ Notes: - The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application. - Applications can define roles to follow least‑privilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier. +#### Uploading files + +The generated `Twenty` client includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood. + +```typescript +import Twenty from '~/generated'; +import * as fs from 'fs'; + +const client = new Twenty(); + +const fileBuffer = fs.readFileSync('./invoice.pdf'); + +const uploadedFile = await client.uploadFile( + fileBuffer, // file contents as a Buffer + 'invoice.pdf', // filename + 'application/pdf', // MIME type (defaults to 'application/octet-stream') + '58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier +); + +console.log(uploadedFile); +// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' } +``` + +The method signature: + +```typescript +uploadFile( + fileBuffer: Buffer, + filename: string, + contentType: string, + fieldMetadataUniversalIdentifier: string, +): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }> +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `fileBuffer` | `Buffer` | The raw file contents | +| `filename` | `string` | The name of the file (used for storage and display) | +| `contentType` | `string` | MIME type of the file (defaults to `application/octet-stream` if omitted) | +| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object | + +Key points: +- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved. +- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else. +- The returned `url` is a signed URL you can use to access the uploaded file. ### Hello World example diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c8aa14be5d..57b72c70ad 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2333,6 +2333,7 @@ export type Mutation = { /** @deprecated Use uploadFilesFieldFile instead */ uploadFile: SignedFile; uploadFilesFieldFile: FileWithSignedUrl; + uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl; uploadImage: SignedFile; uploadWorkflowFile: FileWithSignedUrl; uploadWorkspaceLogo: FileWithSignedUrl; @@ -3152,6 +3153,12 @@ export type MutationUploadFilesFieldFileArgs = { }; +export type MutationUploadFilesFieldFileByUniversalIdentifierArgs = { + fieldMetadataUniversalIdentifier: Scalars['String']; + file: Scalars['Upload']; +}; + + export type MutationUploadImageArgs = { file: Scalars['Upload']; fileFolder?: InputMaybe; diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index dc1241d046..b0620318fc 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -67,6 +67,7 @@ export class ClientService { const defaultOptions: ClientOptions = { url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`, + metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`, headers: { 'Content-Type': 'application/json', Authorization: \`Bearer \${process.env.${DEFAULT_API_KEY_NAME}}\`, @@ -75,16 +76,23 @@ const defaultOptions: ClientOptions = { export default class Twenty { private client: Client; + private url: string; + private metadataUrl: string; + private authorizationToken: string; constructor(options?: ClientOptions) { - this.client = createClient({ + const merged: ClientOptions = { ...defaultOptions, ...options, headers: { ...defaultOptions.headers, ...(options?.headers ?? {}), }, - }); + }; + this.client = createClient(merged); + this.url = merged.url; + this.metadataUrl = merged.metadataUrl; + this.authorizationToken = merged.headers.Authorization; } query(request: R & { __name?: string }) { @@ -94,6 +102,49 @@ export default class Twenty { mutation(request: R & { __name?: string }) { return this.client.mutation(request); } + + async uploadFile( + fileBuffer: Buffer, + filename: string, + contentType: string = 'application/octet-stream', + fieldMetadataUniversalIdentifier: string, + ): Promise<{ + id: string; + path: string; + size: number; + createdAt: string; + url: string; + }> { + const form = new FormData(); + + form.append( + 'operations', + JSON.stringify({ + query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) { + uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url } + }\`, + variables: { file: null, fieldMetadataUniversalIdentifier }, + }), + ); + form.append('map', JSON.stringify({ '0': ['variables.file'] })); + form.append('0', new Blob([fileBuffer], { type: contentType }), filename); + + const response = await fetch(this.metadataUrl, { + method: 'POST', + headers: { + Authorization: this.authorizationToken, + }, + body: form, + }); + + const result = await response.json(); + + if (result.errors) { + throw new GenqlError(result.errors, result.data); + } + + return result.data.uploadFilesFieldFileByUniversalIdentifier; + } } `; diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts index 01b5e9ac89..cef7ce8c67 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts @@ -4,6 +4,7 @@ import { CustomException } from 'src/utils/custom-exception'; export enum FilesFieldExceptionCode { FILE_DELETION_FAILED = 'FILE_DELETION_FAILED', + BAD_REQUEST = 'BAD_REQUEST', TEMPORARY_FILE_NOT_ALLOWED = 'TEMPORARY_FILE_NOT_ALLOWED', } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts index e17065b8f5..2ccf58eb55 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -48,4 +48,29 @@ export class FilesFieldResolver { fieldMetadataId, }); } + + @Mutation(() => FileWithSignedUrlDto) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + async uploadFilesFieldFileByUniversalIdentifier( + @AuthWorkspace() + { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename }: FileUpload, + @Args({ + name: 'fieldMetadataUniversalIdentifier', + type: () => String, + nullable: false, + }) + fieldMetadataUniversalIdentifier: string, + ): Promise { + const stream = createReadStream(); + const buffer = await streamToBuffer(stream); + + return await this.filesFieldService.uploadFile({ + file: buffer, + filename, + workspaceId, + fieldMetadataUniversalIdentifier, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts index 303b267ed9..53e75f12a9 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts @@ -35,12 +35,24 @@ export class FilesFieldService { filename, workspaceId, fieldMetadataId, + fieldMetadataUniversalIdentifier, }: { file: Buffer; filename: string; workspaceId: string; - fieldMetadataId: string; + fieldMetadataId?: string; + fieldMetadataUniversalIdentifier?: string; }): Promise { + if (!fieldMetadataId && !fieldMetadataUniversalIdentifier) { + throw new FilesFieldException( + 'fieldMetadataId or fieldMetadataUniversalIdentifier must be provided', + FilesFieldExceptionCode.BAD_REQUEST, + { + userFriendlyMessage: msg`fieldMetadataId or fieldMetadataUniversalIdentifier must be provided`, + }, + ); + } + const { mimeType, ext } = await extractFileInfo({ file, filename, @@ -54,7 +66,10 @@ export class FilesFieldService { const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({ select: ['applicationId', 'universalIdentifier'], where: { - id: fieldMetadataId, + ...(fieldMetadataId ? { id: fieldMetadataId } : {}), + ...(fieldMetadataUniversalIdentifier + ? { universalIdentifier: fieldMetadataUniversalIdentifier } + : {}), workspaceId, }, });