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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user