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<any> => {
  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 

<img width="1512" height="859" alt="image"
src="https://github.com/user-attachments/assets/57dc3a6b-fd2c-4f21-8331-8f1f05403826"
/>
This commit is contained in:
martmull
2026-02-20 18:11:59 +01:00
committed by GitHub
parent cd1a2712d5
commit ba59ab8094
6 changed files with 148 additions and 4 deletions
@@ -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 leastprivilege. 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
@@ -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<FileFolder>;
@@ -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<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
@@ -94,6 +102,49 @@ export default class Twenty {
mutation<R extends MutationGenqlSelection>(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;
}
}
`;
@@ -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',
}
@@ -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<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return await this.filesFieldService.uploadFile({
file: buffer,
filename,
workspaceId,
fieldMetadataUniversalIdentifier,
});
}
}
@@ -35,12 +35,24 @@ export class FilesFieldService {
filename,
workspaceId,
fieldMetadataId,
fieldMetadataUniversalIdentifier,
}: {
file: Buffer;
filename: string;
workspaceId: string;
fieldMetadataId: string;
fieldMetadataId?: string;
fieldMetadataUniversalIdentifier?: string;
}): Promise<FileWithSignedUrlDto> {
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,
},
});