From 5996d0fc03a04a641650647fac86876cc0f836b9 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 30 Jan 2026 15:42:36 +0100 Subject: [PATCH] Refactor workflow to use new functions (#17552) Removes the versioning system for logic functions (`latestVersion`, `publishedVersions`) and simplifies the file storage structure. ### Changes - Remove `publishOneLogicFunctionOrFail` and publishing logic from workflow status updates - Add `createLogicFunctionFromExistingLogicFunction` to duplicate logic functions when creating draft workflow versions - Update `createDraftStep` to create a new logic function copy instead of referencing the same one - Migrate file storage to v2 endpoints with `applicationUniversalIdentifier` - Unify path structure: source files at `source/workflow/{id}/`, built files at `built-logic-function/workflow/{id}/` - Store full paths in `sourceHandlerPath` and `builtHandlerPath` entity fields --- .../src/generated-metadata/graphql.ts | 55 +- .../twenty-front/src/generated/graphql.ts | 15 +- .../generated/workflowVersion payloads.json | 39 + .../mutations/publishOneLogicFunction.ts | 11 - .../hooks/usePublishOneLogicFunction.ts | 34 - ...769710304101-updateLogicFunctionDefault.ts | 19 + .../application/application.resolver.ts | 4 +- .../__tests__/file-storage.service.spec.ts | 113 ++- .../interfaces/storage-driver.interface.ts | 31 +- .../file-storage/drivers/local.driver.ts | 164 +++-- .../file-storage/drivers/s3.driver.ts | 680 ++++++++---------- .../file-storage/file-storage.service.ts | 266 +++++-- .../services/file-upload.service.ts | 2 +- .../file/files-field/files-field.service.ts | 8 +- .../file/interfaces/file-folder.interface.ts | 8 +- .../file/services/file.service.ts | 9 +- .../__tests__/check-file-path.utils.spec.ts | 35 - .../file/utils/check-file-path.utils.ts | 32 - ...ogic-function-executor-driver.interface.ts | 22 +- .../drivers/lambda.driver.ts | 27 +- .../drivers/local.driver.ts | 44 +- .../logic-function-executor.service.ts | 23 +- ...et-logic-function-folder-or-throw.utils.ts | 22 - .../code-interpreter-tool.ts | 4 +- .../function-build/function-build.service.ts | 87 ++- .../dtos/publish-logic-function.input.ts | 9 - .../logic-function/logic-function.entity.ts | 2 +- .../logic-function/logic-function.resolver.ts | 24 - .../logic-function/logic-function.service.ts | 190 +++-- ...ction-input-to-flat-logic-function.util.ts | 19 +- ...et-logic-function-base-folder-path.util.ts | 12 + .../data/services/dev-seeder-data.service.ts | 2 +- ...e-logic-function-action-handler.service.ts | 131 +++- ...e-logic-function-action-handler.service.ts | 65 +- ...e-logic-function-action-handler.service.ts | 90 ++- ...migration-runner-action-handlers.module.ts | 8 +- ...rsion-step-operations.workspace-service.ts | 27 +- .../workflow-statuses-update.job.spec.ts | 325 --------- .../jobs/workflow-statuses-update.job.ts | 58 -- ...gic-function-execution.integration-spec.ts | 21 - ...blish-logic-function-query-factory.util.ts | 29 - .../utils/publish-logic-function.util.ts | 47 -- .../twenty-shared/src/types/FileFolder.ts | 4 +- 43 files changed, 1213 insertions(+), 1604 deletions(-) create mode 100644 packages/twenty-front/src/generated/workflowVersion payloads.json delete mode 100644 packages/twenty-front/src/modules/settings/logic-functions/graphql/mutations/publishOneLogicFunction.ts delete mode 100644 packages/twenty-front/src/modules/settings/logic-functions/hooks/usePublishOneLogicFunction.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1769710304101-updateLogicFunctionDefault.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/file/utils/__tests__/check-file-path.utils.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/file/utils/check-file-path.utils.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/publish-logic-function.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts delete mode 100644 packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function-query-factory.util.ts delete mode 100644 packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index cb63ebf82c..73ffc60019 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1549,11 +1549,9 @@ export enum FileFolder { AgentChat = 'AgentChat', Attachment = 'Attachment', BuiltFrontComponent = 'BuiltFrontComponent', - BuiltFunction = 'BuiltFunction', + BuiltLogicFunction = 'BuiltLogicFunction', File = 'File', FilesField = 'FilesField', - LogicFunction = 'LogicFunction', - LogicFunctionToDelete = 'LogicFunctionToDelete', PersonPicture = 'PersonPicture', ProfilePicture = 'ProfilePicture', PublicAsset = 'PublicAsset', @@ -2105,7 +2103,6 @@ export type Mutation = { initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; installApplication: Scalars['Boolean']; - publishLogicFunction: LogicFunction; removeQueryFromEventStream: Scalars['Boolean']; removeRoleFromAgent: Scalars['Boolean']; renewToken: AuthTokens; @@ -2712,11 +2709,6 @@ export type MutationInstallApplicationArgs = { }; -export type MutationPublishLogicFunctionArgs = { - input: PublishLogicFunctionInput; -}; - - export type MutationRemoveQueryFromEventStreamArgs = { input: RemoveQueryFromEventStreamInput; }; @@ -3522,11 +3514,6 @@ export type PublicWorkspaceDataOutput = { workspaceUrls: WorkspaceUrls; }; -export type PublishLogicFunctionInput = { - /** The id of the function. */ - id: Scalars['ID']; -}; - export type Query = { __typename?: 'Query'; agentTurns: Array; @@ -6287,13 +6274,6 @@ export type ExecuteOneLogicFunctionMutationVariables = Exact<{ export type ExecuteOneLogicFunctionMutation = { __typename?: 'Mutation', executeOneLogicFunction: { __typename?: 'LogicFunctionExecutionResult', data?: any | null, logs: string, duration: number, status: LogicFunctionExecutionStatus, error?: any | null } }; -export type PublishOneLogicFunctionMutationVariables = Exact<{ - input: PublishLogicFunctionInput; -}>; - - -export type PublishOneLogicFunctionMutation = { __typename?: 'Mutation', publishLogicFunction: { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, builtHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string } }; - export type UpdateOneLogicFunctionMutationVariables = Exact<{ input: UpdateLogicFunctionInput; }>; @@ -12453,39 +12433,6 @@ export function useExecuteOneLogicFunctionMutation(baseOptions?: Apollo.Mutation export type ExecuteOneLogicFunctionMutationHookResult = ReturnType; export type ExecuteOneLogicFunctionMutationResult = Apollo.MutationResult; export type ExecuteOneLogicFunctionMutationOptions = Apollo.BaseMutationOptions; -export const PublishOneLogicFunctionDocument = gql` - mutation PublishOneLogicFunction($input: PublishLogicFunctionInput!) { - publishLogicFunction(input: $input) { - ...LogicFunctionFields - } -} - ${LogicFunctionFieldsFragmentDoc}`; -export type PublishOneLogicFunctionMutationFn = Apollo.MutationFunction; - -/** - * __usePublishOneLogicFunctionMutation__ - * - * To run a mutation, you first call `usePublishOneLogicFunctionMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `usePublishOneLogicFunctionMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [publishOneLogicFunctionMutation, { data, loading, error }] = usePublishOneLogicFunctionMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function usePublishOneLogicFunctionMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(PublishOneLogicFunctionDocument, options); - } -export type PublishOneLogicFunctionMutationHookResult = ReturnType; -export type PublishOneLogicFunctionMutationResult = Apollo.MutationResult; -export type PublishOneLogicFunctionMutationOptions = Apollo.BaseMutationOptions; export const UpdateOneLogicFunctionDocument = gql` mutation UpdateOneLogicFunction($input: UpdateLogicFunctionInput!) { updateOneLogicFunction(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 9dc93cd3ae..7531213f88 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1516,11 +1516,9 @@ export enum FileFolder { AgentChat = 'AgentChat', Attachment = 'Attachment', BuiltFrontComponent = 'BuiltFrontComponent', - BuiltFunction = 'BuiltFunction', + BuiltLogicFunction = 'BuiltLogicFunction', File = 'File', FilesField = 'FilesField', - LogicFunction = 'LogicFunction', - LogicFunctionToDelete = 'LogicFunctionToDelete', PersonPicture = 'PersonPicture', ProfilePicture = 'ProfilePicture', PublicAsset = 'PublicAsset', @@ -2062,7 +2060,6 @@ export type Mutation = { initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; installApplication: Scalars['Boolean']; - publishLogicFunction: LogicFunction; removeQueryFromEventStream: Scalars['Boolean']; removeRoleFromAgent: Scalars['Boolean']; renewToken: AuthTokens; @@ -2615,11 +2612,6 @@ export type MutationInstallApplicationArgs = { }; -export type MutationPublishLogicFunctionArgs = { - input: PublishLogicFunctionInput; -}; - - export type MutationRemoveQueryFromEventStreamArgs = { input: RemoveQueryFromEventStreamInput; }; @@ -3404,11 +3396,6 @@ export type PublicWorkspaceDataOutput = { workspaceUrls: WorkspaceUrls; }; -export type PublishLogicFunctionInput = { - /** The id of the function. */ - id: Scalars['ID']; -}; - export type Query = { __typename?: 'Query'; apiKey?: Maybe; diff --git a/packages/twenty-front/src/generated/workflowVersion payloads.json b/packages/twenty-front/src/generated/workflowVersion payloads.json new file mode 100644 index 0000000000..1cfbbf71d1 --- /dev/null +++ b/packages/twenty-front/src/generated/workflowVersion payloads.json @@ -0,0 +1,39 @@ +On v1.16 + +"settings": {"input": {"serverlessFunctionId": "e8a6a1c8-3e63-4d8b-aaa7-d8c932a6b314", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "draft"}, "outputSchema": {"message": {"type": "string", "label": "message", "value": "Hello, input: null and null", "isLeaf": true}} +"settings": {"input": {"serverlessFunctionId": "84d3e16c-10bb-4eed-ae15-714f657a2726", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "draft"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "cfc3c41f-5520-41f5-a83e-6a56ca6b90ba", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "42b11c8c-0238-4044-a5d3-9cc662066de4", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "42b11c8c-0238-4044-a5d3-9cc662066de4", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "draft"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "a7d07444-092f-4a96-8e3c-b379bc49f6e3", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "draft"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "8fdff250-0b7d-4d20-ae5f-8defe4ff9d37", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} +"settings": {"input": {"serverlessFunctionId": "798f33e3-04ee-453f-94a1-ddc1518e4618", "serverlessFunctionInput": {"a": null, "b": null}, "serverlessFunctionVersion": "1"}, "outputSchema": {"link": {"tab": "test", "icon": "IconVariable", "label": "Generate Function Output", "isLeaf": true}, "_outputSchemaType": "LINK"} + +built-function/42b11c8c-0238-4044-a5d3-9cc662066de4/1/index.mjs +built-function/42b11c8c-0238-4044-a5d3-9cc662066de4/draft/index.mjs +built-function/798f33e3-04ee-453f-94a1-ddc1518e4618/1/index.mjs +built-function/798f33e3-04ee-453f-94a1-ddc1518e4618/draft/index.mjs +built-function/84d3e16c-10bb-4eed-ae15-714f657a2726/draft/index.mjs +built-function/8fdff250-0b7d-4d20-ae5f-8defe4ff9d37/1/index.mjs +built-function/8fdff250-0b7d-4d20-ae5f-8defe4ff9d37/draft/index.mjs +built-function/a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6/1/index.mjs +built-function/a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6/draft/index.mjs +built-function/a7d07444-092f-4a96-8e3c-b379bc49f6e3/draft/index.mjs +built-function/cfc3c41f-5520-41f5-a83e-6a56ca6b90ba/1/index.mjs +built-function/cfc3c41f-5520-41f5-a83e-6a56ca6b90ba/draft/index.mjs +built-function/e8a6a1c8-3e63-4d8b-aaa7-d8c932a6b314/draft/index.mjs +serverless-function/42b11c8c-0238-4044-a5d3-9cc662066de4/1/src/index.ts +serverless-function/42b11c8c-0238-4044-a5d3-9cc662066de4/draft/src/index.ts +serverless-function/798f33e3-04ee-453f-94a1-ddc1518e4618/1/src/index.ts +serverless-function/798f33e3-04ee-453f-94a1-ddc1518e4618/draft/src/index.ts +serverless-function/84d3e16c-10bb-4eed-ae15-714f657a2726/draft/src/index.ts +serverless-function/8fdff250-0b7d-4d20-ae5f-8defe4ff9d37/1/src/index.ts +serverless-function/8fdff250-0b7d-4d20-ae5f-8defe4ff9d37/draft/src/index.ts +serverless-function/a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6/1/src/index.ts +serverless-function/a3d42cb4-a5bd-4eb1-b564-5a207fa98dc6/draft/src/index.ts +serverless-function/a7d07444-092f-4a96-8e3c-b379bc49f6e3/draft/src/index.ts +serverless-function/cfc3c41f-5520-41f5-a83e-6a56ca6b90ba/1/src/index.ts +serverless-function/cfc3c41f-5520-41f5-a83e-6a56ca6b90ba/draft/src/index.ts +serverless-function/e8a6a1c8-3e63-4d8b-aaa7-d8c932a6b314/draft/src/index.ts diff --git a/packages/twenty-front/src/modules/settings/logic-functions/graphql/mutations/publishOneLogicFunction.ts b/packages/twenty-front/src/modules/settings/logic-functions/graphql/mutations/publishOneLogicFunction.ts deleted file mode 100644 index c0013bddfe..0000000000 --- a/packages/twenty-front/src/modules/settings/logic-functions/graphql/mutations/publishOneLogicFunction.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { gql } from '@apollo/client'; -import { LOGIC_FUNCTION_FRAGMENT } from '@/settings/logic-functions/graphql/fragments/logicFunctionFragment'; - -export const PUBLISH_ONE_LOGIC_FUNCTION = gql` - ${LOGIC_FUNCTION_FRAGMENT} - mutation PublishOneLogicFunction($input: PublishLogicFunctionInput!) { - publishLogicFunction(input: $input) { - ...LogicFunctionFields - } - } -`; diff --git a/packages/twenty-front/src/modules/settings/logic-functions/hooks/usePublishOneLogicFunction.ts b/packages/twenty-front/src/modules/settings/logic-functions/hooks/usePublishOneLogicFunction.ts deleted file mode 100644 index abf416b22f..0000000000 --- a/packages/twenty-front/src/modules/settings/logic-functions/hooks/usePublishOneLogicFunction.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { PUBLISH_ONE_LOGIC_FUNCTION } from '@/settings/logic-functions/graphql/mutations/publishOneLogicFunction'; -import { FIND_ONE_LOGIC_FUNCTION_SOURCE_CODE } from '@/settings/logic-functions/graphql/queries/findOneLogicFunctionSourceCode'; -import { useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; -import { - type PublishOneLogicFunctionMutation, - type PublishOneLogicFunctionMutationVariables, - type PublishLogicFunctionInput, -} from '~/generated-metadata/graphql'; - -export const usePublishOneLogicFunction = () => { - const apolloMetadataClient = useApolloCoreClient(); - const [mutate] = useMutation< - PublishOneLogicFunctionMutation, - PublishOneLogicFunctionMutationVariables - >(PUBLISH_ONE_LOGIC_FUNCTION, { - client: apolloMetadataClient, - }); - - const publishOneLogicFunction = async (input: PublishLogicFunctionInput) => { - return await mutate({ - variables: { - input, - }, - awaitRefetchQueries: true, - refetchQueries: [ - getOperationName(FIND_ONE_LOGIC_FUNCTION_SOURCE_CODE) ?? '', - ], - }); - }; - - return { publishOneLogicFunction }; -}; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769710304101-updateLogicFunctionDefault.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769710304101-updateLogicFunctionDefault.ts new file mode 100644 index 0000000000..c859ee46eb --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769710304101-updateLogicFunctionDefault.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class UpdateLogicFunctionDefault1769710304101 + implements MigrationInterface +{ + name = 'UpdateLogicFunctionDefault1769710304101'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."logicFunction" ALTER COLUMN "builtHandlerPath" SET DEFAULT 'src/index.mjs'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."logicFunction" ALTER COLUMN "builtHandlerPath" SET DEFAULT 'index.mjs'`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts index c577c55084..51a07d4b57 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts @@ -151,7 +151,7 @@ export class ApplicationResolver { }: UploadApplicationFileInput, ): Promise { const allowedApplicationFileFolders: FileFolder[] = [ - FileFolder.BuiltFunction, + FileFolder.BuiltLogicFunction, FileFolder.BuiltFrontComponent, FileFolder.PublicAsset, FileFolder.Source, @@ -178,7 +178,7 @@ export class ApplicationResolver { dirname, ); - await this.fileStorageService.write({ + await this.fileStorageService.writeFile({ file: buffer, name: filename, folder: folderPath, diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts index afd9a1f2c3..894d3ddded 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts @@ -60,22 +60,21 @@ describe('FileStorageService', () => { beforeEach(() => { mockDriver = { - write: jest.fn(), - read: jest.fn(), + writeFile: jest.fn(), + readFile: jest.fn(), delete: jest.fn(), move: jest.fn(), copy: jest.fn(), - download: jest.fn(), + downloadFolder: jest.fn(), + uploadFolder: jest.fn(), checkFileExists: jest.fn(), checkFolderExists: jest.fn(), - writeFolder: jest.fn(), - readFolder: jest.fn(), }; mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver); }); - describe('write', () => { + describe('writeFile', () => { it('should delegate to the current driver', async () => { const writeParams = { file: Buffer.from('test content'), @@ -84,12 +83,12 @@ describe('FileStorageService', () => { mimeType: 'text/plain', }; - mockDriver.write.mockResolvedValue(undefined); + mockDriver.writeFile.mockResolvedValue(undefined); - await service.write(writeParams); + await service.writeFile(writeParams); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.write).toHaveBeenCalledWith({ + expect(mockDriver.writeFile).toHaveBeenCalledWith({ filePath: 'documents/test.txt', sourceFile: writeParams.file, mimeType: 'text/plain', @@ -106,30 +105,29 @@ describe('FileStorageService', () => { const error = new Error('Write failed'); - mockDriver.write.mockRejectedValue(error); + mockDriver.writeFile.mockRejectedValue(error); - await expect(service.write(writeParams)).rejects.toThrow( + await expect(service.writeFile(writeParams)).rejects.toThrow( 'Write failed', ); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); }); }); - describe('read', () => { + describe('readFile', () => { it('should delegate to the current driver', async () => { const readParams = { - folderPath: 'documents', - filename: 'test.txt', + filePath: 'documents/test.txt', }; const mockStream = new Readable(); - mockDriver.read.mockResolvedValue(mockStream); + mockDriver.readFile.mockResolvedValue(mockStream); - const result = await service.read(readParams); + const result = await service.readFile(readParams); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.read).toHaveBeenCalledWith({ + expect(mockDriver.readFile).toHaveBeenCalledWith({ filePath: 'documents/test.txt', }); expect(result).toBe(mockStream); @@ -137,15 +135,16 @@ describe('FileStorageService', () => { it('should handle read errors', async () => { const readParams = { - folderPath: 'documents', - filename: 'test.txt', + filePath: 'documents/test.txt', }; const error = new Error('Read failed'); - mockDriver.read.mockRejectedValue(error); + mockDriver.readFile.mockRejectedValue(error); - await expect(service.read(readParams)).rejects.toThrow('Read failed'); + await expect(service.readFile(readParams)).rejects.toThrow( + 'Read failed', + ); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); }); }); @@ -258,44 +257,10 @@ describe('FileStorageService', () => { }); }); - describe('download', () => { - it('should delegate to the current driver', async () => { - const downloadParams = { - from: { folderPath: 'documents', filename: 'test.txt' }, - to: { folderPath: '/tmp', filename: 'downloaded-test.txt' }, - }; - - mockDriver.download.mockResolvedValue(undefined); - - await service.download(downloadParams); - - expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.download).toHaveBeenCalledWith(downloadParams); - }); - - it('should handle download errors', async () => { - const downloadParams = { - from: { folderPath: 'documents', filename: 'test.txt' }, - to: { folderPath: '/tmp', filename: 'downloaded-test.txt' }, - }; - - const error = new Error('Download failed'); - - mockDriver.download.mockRejectedValue(error); - - await expect(service.download(downloadParams)).rejects.toThrow( - 'Download failed', - ); - expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.download).toHaveBeenCalledWith(downloadParams); - }); - }); - describe('checkFileExists', () => { it('should delegate to the current driver and return true', async () => { const checkParams = { - folderPath: 'documents', - filename: 'test.txt', + filePath: 'documents/test.txt', }; mockDriver.checkFileExists.mockResolvedValue(true); @@ -309,8 +274,7 @@ describe('FileStorageService', () => { it('should delegate to the current driver and return false', async () => { const checkParams = { - folderPath: 'documents', - filename: 'nonexistent.txt', + filePath: 'documents/nonexistent.txt', }; mockDriver.checkFileExists.mockResolvedValue(false); @@ -324,8 +288,7 @@ describe('FileStorageService', () => { it('should handle checkFileExists errors', async () => { const checkParams = { - folderPath: 'documents', - filename: 'test.txt', + filePath: 'documents/test.txt', }; const error = new Error('Check failed'); @@ -339,5 +302,35 @@ describe('FileStorageService', () => { expect(mockDriver.checkFileExists).toHaveBeenCalledWith(checkParams); }); }); + + describe('checkFolderExists', () => { + it('should delegate to the current driver and return true', async () => { + const checkParams = { + folderPath: 'documents', + }; + + mockDriver.checkFolderExists.mockResolvedValue(true); + + const result = await service.checkFolderExists(checkParams); + + expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); + expect(mockDriver.checkFolderExists).toHaveBeenCalledWith(checkParams); + expect(result).toBe(true); + }); + + it('should delegate to the current driver and return false', async () => { + const checkParams = { + folderPath: 'nonexistent', + }; + + mockDriver.checkFolderExists.mockResolvedValue(false); + + const result = await service.checkFolderExists(checkParams); + + expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); + expect(mockDriver.checkFolderExists).toHaveBeenCalledWith(checkParams); + expect(result).toBe(false); + }); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts index 4e6be7b9f7..1d234ac564 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts @@ -1,17 +1,23 @@ import { type Readable } from 'stream'; -import { type Sources } from 'twenty-shared/types'; - export interface StorageDriver { - delete(params: { folderPath: string; filename?: string }): Promise; - read(params: { filePath: string }): Promise; - readFolder(folderPath: string): Promise; - write(params: { + readFile(params: { filePath: string }): Promise; + writeFile(params: { filePath: string; sourceFile: Buffer | Uint8Array | string; mimeType: string | undefined; }): Promise; - writeFolder(sources: Sources, folderPath: string): Promise; + + downloadFolder(params: { + onStoragePath: string; + localPath: string; + }): Promise; + uploadFolder(params: { + localPath: string; + onStoragePath: string; + }): Promise; + + delete(params: { folderPath: string; filename?: string }): Promise; move(params: { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; @@ -20,14 +26,7 @@ export interface StorageDriver { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; }): Promise; - download(params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }): Promise; - checkFileExists(params: { - folderPath: string; - filename: string; - }): Promise; - checkFolderExists(folderPath: string): Promise; + checkFileExists(params: { filePath: string }): Promise; + checkFolderExists(params: { folderPath: string }): Promise; } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts index 11e945069b..739414551f 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts @@ -3,9 +3,6 @@ import * as fs from 'fs/promises'; import path, { dirname, join } from 'path'; import { type Readable } from 'stream'; -import { isObject } from '@sniptt/guards'; -import { type Sources } from 'twenty-shared/types'; - import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface'; import { FileStorageException, @@ -23,51 +20,11 @@ export class LocalDriver implements StorageDriver { this.options = options; } - async createFolder(path: string) { + private async createFolder(path: string) { return fs.mkdir(path, { recursive: true }); } - async write(params: { - filePath: string; - sourceFile: Buffer | Uint8Array | string; - mimeType: string | undefined; - }): Promise { - const filePath = `${this.options.storagePath}/${params.filePath}`; - const folderPath = dirname(filePath); - - await this.createFolder(folderPath); - - await fs.writeFile(filePath, params.sourceFile); - } - - async writeFolder(sources: Sources, folderPath: string) { - for (const key of Object.keys(sources)) { - if (isObject(sources[key])) { - await this.writeFolder(sources[key], join(folderPath, key)); - continue; - } - await this.write({ - filePath: join(folderPath, key), - sourceFile: sources[key], - mimeType: undefined, - }); - } - } - - async delete(params: { - folderPath: string; - filename?: string; - }): Promise { - const filePath = join( - `${this.options.storagePath}/`, - params.folderPath, - params.filename || '', - ); - - await fs.rm(filePath, { recursive: true }); - } - - async read(params: { filePath: string }): Promise { + async readFile(params: { filePath: string }): Promise { const joinedPath = join(`${this.options.storagePath}/`, params.filePath); let filePath: string; @@ -82,7 +39,6 @@ export class LocalDriver implements StorageDriver { const storageRoot = realpathSync(path.resolve(this.options.storagePath)); if (!filePath.startsWith(storageRoot + path.sep)) { - // Prevent directory traversal throw new FileStorageException( 'Access denied', FileStorageExceptionCode.FILE_NOT_FOUND, @@ -103,28 +59,87 @@ export class LocalDriver implements StorageDriver { } } - async readFolder(folderPath: string): Promise { - const sources: Sources = {}; + async writeFile(params: { + filePath: string; + sourceFile: Buffer | Uint8Array | string; + mimeType: string | undefined; + }): Promise { + const filePath = `${this.options.storagePath}/${params.filePath}`; + const folderPath = dirname(filePath); - const rootFolderPath = join(`${this.options.storagePath}/`, folderPath); + await this.createFolder(folderPath); + + await fs.writeFile(filePath, params.sourceFile); + } + + async downloadFolder(params: { + onStoragePath: string; + localPath: string; + }): Promise { + const rootFolderPath = join( + `${this.options.storagePath}/`, + params.onStoragePath, + ); + + await this.createFolder(params.localPath); const resources = await fs.readdir(rootFolderPath); for (const resource of resources) { const resourcePath = path.join(rootFolderPath, resource); - const stats = await fs.stat(resourcePath); if (stats.isFile()) { - sources[resource] = await fs.readFile(resourcePath, 'utf8'); + const content = await fs.readFile(resourcePath); + + await fs.writeFile(path.join(params.localPath, resource), content); } else { - sources[resource] = await this.readFolder( - path.join(folderPath, resource), - ); + await this.downloadFolder({ + onStoragePath: path.join(params.onStoragePath, resource), + localPath: path.join(params.localPath, resource), + }); } } + } - return sources; + async uploadFolder(params: { + localPath: string; + onStoragePath: string; + }): Promise { + const resources = await fs.readdir(params.localPath); + + for (const resource of resources) { + const resourcePath = path.join(params.localPath, resource); + const stats = await fs.stat(resourcePath); + + if (stats.isFile()) { + const content = await fs.readFile(resourcePath); + + await this.writeFile({ + filePath: path.join(params.onStoragePath, resource), + sourceFile: content, + mimeType: undefined, + }); + } else { + await this.uploadFolder({ + localPath: resourcePath, + onStoragePath: path.join(params.onStoragePath, resource), + }); + } + } + } + + async delete(params: { + folderPath: string; + filename?: string; + }): Promise { + const filePath = join( + `${this.options.storagePath}/`, + params.folderPath, + params.filename || '', + ); + + await fs.rm(filePath, { recursive: true }); } async move(params: { @@ -159,13 +174,10 @@ export class LocalDriver implements StorageDriver { } } - async copy( - params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }, - toInMemory = false, - ): Promise { + async copy(params: { + from: { folderPath: string; filename?: string }; + to: { folderPath: string; filename?: string }; + }): Promise { if (!params.from.filename && params.to.filename) { throw new Error('Cannot copy folder to file'); } @@ -176,7 +188,7 @@ export class LocalDriver implements StorageDriver { ); const toPath = join( - toInMemory ? '' : this.options.storagePath, + this.options.storagePath, params.to.folderPath, params.to.filename || '', ); @@ -197,28 +209,14 @@ export class LocalDriver implements StorageDriver { } } - async download(params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }): Promise { - await this.copy(params, true); + async checkFileExists(params: { filePath: string }): Promise { + const fullPath = join(this.options.storagePath, params.filePath); + + return existsSync(fullPath); } - async checkFileExists(params: { - folderPath: string; - filename: string; - }): Promise { - const filePath = join( - this.options.storagePath, - params.folderPath, - params.filename, - ); - - return existsSync(filePath); - } - - async checkFolderExists(folderPath: string): Promise { - const folderFullPath = join(this.options.storagePath, folderPath); + async checkFolderExists(params: { folderPath: string }): Promise { + const folderFullPath = join(this.options.storagePath, params.folderPath); return existsSync(folderFullPath); } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts index 0ebc0e78c3..fb9af2e238 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts @@ -1,7 +1,7 @@ import { Logger } from '@nestjs/common'; import fs from 'fs'; -import { mkdir } from 'fs/promises'; +import { mkdir, readdir, readFile } from 'fs/promises'; import { join } from 'path'; import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; @@ -20,7 +20,6 @@ import { S3, type S3ClientConfig, } from '@aws-sdk/client-s3'; -import { isObject } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface'; @@ -29,11 +28,6 @@ import { FileStorageExceptionCode, } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; -import type { Sources } from 'twenty-shared/types'; - -import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content'; -import { readS3FolderContent } from 'src/engine/core-modules/file-storage/utils/read-s3-folder-content'; - export interface S3DriverOptions extends S3ClientConfig { bucketName: string; endpoint?: string; @@ -60,114 +54,7 @@ export class S3Driver implements StorageDriver { return this.s3Client; } - async write(params: { - filePath: string; - sourceFile: Buffer | Uint8Array | string; - mimeType: string | undefined; - }): Promise { - const command = new PutObjectCommand({ - Key: params.filePath, - Body: params.sourceFile, - ContentType: params.mimeType, - Bucket: this.bucketName, - }); - - await this.s3Client.send(command); - } - - async writeFolder(sources: Sources, folderPath: string) { - for (const key of Object.keys(sources)) { - if (isObject(sources[key])) { - await this.writeFolder(sources[key], join(folderPath, key)); - continue; - } - await this.write({ - filePath: `${folderPath}/${key}`, - sourceFile: sources[key], - mimeType: undefined, - }); - } - } - - private async fetchS3FolderContents(folderPath: string) { - const listParams = { - Bucket: this.bucketName, - Prefix: folderPath, - }; - - const listObjectsCommand = new ListObjectsV2Command(listParams); - const listedObjects = await this.s3Client.send(listObjectsCommand); - - return listedObjects; - } - - // @ts-expect-error legacy noImplicitAny - private async emptyS3Directory(folderPath) { - this.logger.log(`${folderPath} - emptying folder`); - - const listedObjects = await this.fetchS3FolderContents(folderPath); - - this.logger.log( - `${folderPath} - listed objects`, - listedObjects.Contents, - listedObjects.IsTruncated, - listedObjects.Contents?.length, - ); - - if (listedObjects.Contents?.length === 0) return; - - const deleteParams = { - Bucket: this.bucketName, - Delete: { - Objects: listedObjects.Contents?.map(({ Key }) => { - return { Key }; - }), - }, - }; - - const deleteObjectCommand = new DeleteObjectsCommand(deleteParams); - - await this.s3Client.send(deleteObjectCommand); - - this.logger.log(`${folderPath} - objects deleted`); - - if (listedObjects.IsTruncated) { - this.logger.log(`${folderPath} - folder is truncated`); - - await this.emptyS3Directory(folderPath); - } - } - - async delete(params: { - folderPath: string; - filename?: string; - }): Promise { - this.logger.log( - `${params.folderPath} - deleting file ${params.filename} from folder ${params.folderPath}`, - ); - - if (params.filename) { - const deleteCommand = new DeleteObjectCommand({ - Key: `${params.folderPath}/${params.filename}`, - Bucket: this.bucketName, - }); - - await this.s3Client.send(deleteCommand); - } else { - await this.emptyS3Directory(params.folderPath); - - this.logger.log(`${params.folderPath} - folder is empty`); - - const deleteEmptyFolderCommand = new DeleteObjectCommand({ - Key: `${params.folderPath}`, - Bucket: this.bucketName, - }); - - await this.s3Client.send(deleteEmptyFolderCommand); - } - } - - async read(params: { filePath: string }): Promise { + async readFile(params: { filePath: string }): Promise { const command = new GetObjectCommand({ Key: params.filePath, Bucket: this.bucketName, @@ -193,44 +80,109 @@ export class S3Driver implements StorageDriver { } } - async readFolder(folderPath: string): Promise { - const sources: Sources = {}; - const listedObjects = await this.fetchS3FolderContents(folderPath); + async writeFile(params: { + filePath: string; + sourceFile: Buffer | Uint8Array | string; + mimeType: string | undefined; + }): Promise { + const command = new PutObjectCommand({ + Key: params.filePath, + Body: params.sourceFile, + ContentType: params.mimeType, + Bucket: this.bucketName, + }); + + await this.s3Client.send(command); + } + + async downloadFolder(params: { + onStoragePath: string; + localPath: string; + }): Promise { + const listedObjects = await this.fetchS3FolderContents( + params.onStoragePath, + ); if (!listedObjects.Contents || listedObjects.Contents.length === 0) { - return sources; + return; } - const files = ( - await Promise.all( - listedObjects.Contents.map(async (object) => { - if (!object.Key) { - return; - } + for (const object of listedObjects.Contents) { + const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); - const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); + if (!isDefined(folderAndFilePaths)) { + continue; + } - if (!isDefined(folderAndFilePaths)) { - return; - } + const { fromFolderPath, filename } = folderAndFilePaths; - const { fromFolderPath, filename } = folderAndFilePaths; + const relativePath = fromFolderPath + .replace(params.onStoragePath + '/', '') + .replace(params.onStoragePath, ''); - const fileContent = await readFileContent( - await this.read({ filePath: `${fromFolderPath}/${filename}` }), - ); + const localFolderPath = relativePath + ? join(params.localPath, relativePath) + : params.localPath; - const formattedObjectKey = object.Key.replace( - folderPath + '/', - '', - ).replace(folderPath, ''); + await mkdir(localFolderPath, { recursive: true }); - return { path: formattedObjectKey, fileContent }; - }), - ) - ).filter(isDefined); + const fileStream = await this.readFile({ + filePath: `${fromFolderPath}/${filename}`, + }); - return readS3FolderContent(files); + const toPath = join(localFolderPath, filename); + + await pipeline(fileStream, fs.createWriteStream(toPath)); + } + } + + async uploadFolder(params: { + localPath: string; + onStoragePath: string; + }): Promise { + const entries = await readdir(params.localPath, { withFileTypes: true }); + + for (const entry of entries) { + const localEntryPath = join(params.localPath, entry.name); + + if (entry.isDirectory()) { + await this.uploadFolder({ + localPath: localEntryPath, + onStoragePath: join(params.onStoragePath, entry.name), + }); + } else { + const fileContent = await readFile(localEntryPath); + + await this.writeFile({ + filePath: `${params.onStoragePath}/${entry.name}`, + sourceFile: fileContent, + mimeType: undefined, + }); + } + } + } + + async delete(params: { + folderPath: string; + filename?: string; + }): Promise { + if (params.filename) { + const deleteCommand = new DeleteObjectCommand({ + Key: `${params.folderPath}/${params.filename}`, + Bucket: this.bucketName, + }); + + await this.s3Client.send(deleteCommand); + } else { + await this.emptyS3Directory(params.folderPath); + + const deleteEmptyFolderCommand = new DeleteObjectCommand({ + Key: `${params.folderPath}`, + Bucket: this.bucketName, + }); + + await this.s3Client.send(deleteEmptyFolderCommand); + } } async move(params: { @@ -247,7 +199,6 @@ export class S3Driver implements StorageDriver { const toKey = `${params.to.folderPath}/${params.to.filename}`; try { - // Check if the source file exists await this.s3Client.send( new HeadObjectCommand({ Bucket: this.bucketName, @@ -255,7 +206,6 @@ export class S3Driver implements StorageDriver { }), ); - // Copy the object to the new location await this.s3Client.send( new CopyObjectCommand({ CopySource: `${this.bucketName}/${fromKey}`, @@ -264,7 +214,6 @@ export class S3Driver implements StorageDriver { }), ); - // Delete the original object await this.s3Client.send( new DeleteObjectCommand({ Bucket: this.bucketName, @@ -278,12 +227,204 @@ export class S3Driver implements StorageDriver { FileStorageExceptionCode.FILE_NOT_FOUND, ); } - // For other errors, throw the original error throw error; } } - async moveS3Folder(params: { + async copy(params: { + from: { folderPath: string; filename?: string }; + to: { folderPath: string; filename?: string }; + }): Promise { + if (!params.from.filename && params.to.filename) { + throw new Error('Cannot copy folder to file'); + } + + const fromKey = `${params.from.folderPath}/${params.from.filename || ''}`; + const toKey = `${params.to.folderPath}/${params.to.filename || ''}`; + + if (isDefined(params.from.filename)) { + try { + await this.s3Client.send( + new HeadObjectCommand({ + Bucket: this.bucketName, + Key: fromKey, + }), + ); + + await this.s3Client.send( + new CopyObjectCommand({ + CopySource: `${this.bucketName}/${fromKey}`, + Bucket: this.bucketName, + Key: toKey, + }), + ); + + return; + } catch (error) { + if (error.name === 'NotFound') { + throw new FileStorageException( + 'File not found', + FileStorageExceptionCode.FILE_NOT_FOUND, + ); + } + throw error; + } + } + + const listedObjects = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: fromKey, + }), + ); + + if (!listedObjects.Contents || listedObjects.Contents.length === 0) { + throw new Error(`No objects found in the source folder ${fromKey}.`); + } + + for (const object of listedObjects.Contents) { + const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); + + if (!isDefined(folderAndFilePaths)) { + continue; + } + + const { fromFolderPath, filename } = folderAndFilePaths; + + const toFolderPath = fromFolderPath.replace( + params.from.folderPath, + params.to.folderPath, + ); + + if (!isDefined(toFolderPath)) { + continue; + } + + await this.copy({ + from: { folderPath: fromFolderPath, filename }, + to: { folderPath: toFolderPath, filename }, + }); + } + } + + async checkFileExists(params: { filePath: string }): Promise { + try { + await this.s3Client.send( + new HeadObjectCommand({ + Bucket: this.bucketName, + Key: params.filePath, + }), + ); + } catch (error) { + if (error instanceof NotFound) { + return false; + } + + throw error; + } + + return true; + } + + async checkFolderExists(params: { folderPath: string }): Promise { + try { + const listCommand = new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: params.folderPath, + MaxKeys: 1, + }); + + const result = await this.s3Client.send(listCommand); + + return (result.Contents && result.Contents.length > 0) || false; + } catch (error) { + if (error instanceof NotFound) { + return false; + } + + throw error; + } + } + + async checkBucketExists(args: HeadBucketCommandInput) { + try { + await this.s3Client.headBucket(args); + + return true; + } catch (error) { + if (error instanceof NotFound) { + return false; + } + + throw error; + } + } + + async createBucket(args: CreateBucketCommandInput) { + const exist = await this.checkBucketExists({ + Bucket: args.Bucket, + }); + + if (exist) { + return; + } + + return this.s3Client.createBucket(args); + } + + private async fetchS3FolderContents(folderPath: string) { + const listParams = { + Bucket: this.bucketName, + Prefix: folderPath, + }; + + const listObjectsCommand = new ListObjectsV2Command(listParams); + const listedObjects = await this.s3Client.send(listObjectsCommand); + + return listedObjects; + } + + private async emptyS3Directory(folderPath: string) { + const listedObjects = await this.fetchS3FolderContents(folderPath); + + if (listedObjects.Contents?.length === 0) return; + + const deleteParams = { + Bucket: this.bucketName, + Delete: { + Objects: listedObjects.Contents?.map(({ Key }) => { + return { Key }; + }), + }, + }; + + const deleteObjectCommand = new DeleteObjectsCommand(deleteParams); + + await this.s3Client.send(deleteObjectCommand); + + if (listedObjects.IsTruncated) { + await this.emptyS3Directory(folderPath); + } + } + + private extractFolderAndFilePaths(objectKey: string | undefined) { + if (!isDefined(objectKey)) { + return; + } + + const result = /(?.*)\/(?.*)/.exec(objectKey); + + if (!isDefined(result) || !isDefined(result.groups)) { + return; + } + + const fromFolderPath = result.groups.folder; + const filename = result.groups.file; + + return { fromFolderPath, filename }; + } + + private async moveS3Folder(params: { from: { folderPath: string }; to: { folderPath: string }; }): Promise { @@ -321,249 +462,4 @@ export class S3Driver implements StorageDriver { }); } } - - extractFolderAndFilePaths(objectKey: string | undefined) { - if (!isDefined(objectKey)) { - return; - } - - const result = /(?.*)\/(?.*)/.exec(objectKey); - - if (!isDefined(result) || !isDefined(result.groups)) { - return; - } - - const fromFolderPath = result.groups.folder; - const filename = result.groups.file; - - return { fromFolderPath, filename }; - } - - async copy(params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }): Promise { - if (!params.from.filename && params.to.filename) { - throw new Error('Cannot copy folder to file'); - } - - const fromKey = `${params.from.folderPath}/${params.from.filename || ''}`; - const toKey = `${params.to.folderPath}/${params.to.filename || ''}`; - - if (isDefined(params.from.filename)) { - try { - // Check if the source file exists - await this.s3Client.send( - new HeadObjectCommand({ - Bucket: this.bucketName, - Key: fromKey, - }), - ); - - // Copy the object to the new location - await this.s3Client.send( - new CopyObjectCommand({ - CopySource: `${this.bucketName}/${fromKey}`, - Bucket: this.bucketName, - Key: toKey, - }), - ); - - return; - } catch (error) { - if (error.name === 'NotFound') { - throw new FileStorageException( - 'File not found', - FileStorageExceptionCode.FILE_NOT_FOUND, - ); - } - // For other errors, throw the original error - throw error; - } - } - - const listedObjects = await this.s3Client.send( - new ListObjectsV2Command({ - Bucket: this.bucketName, - Prefix: fromKey, - }), - ); - - if (!listedObjects.Contents || listedObjects.Contents.length === 0) { - throw new Error(`No objects found in the source folder ${fromKey}.`); - } - - for (const object of listedObjects.Contents) { - const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); - - if (!isDefined(folderAndFilePaths)) { - continue; - } - - const { fromFolderPath, filename } = folderAndFilePaths; - - const toFolderPath = fromFolderPath.replace( - params.from.folderPath, - params.to.folderPath, - ); - - if (!isDefined(toFolderPath)) { - continue; - } - - await this.copy({ - from: { - folderPath: fromFolderPath, - filename, - }, - to: { folderPath: toFolderPath, filename }, - }); - } - } - - async download(params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }): Promise { - if (!params.from.filename && params.to.filename) { - throw new Error('Cannot copy folder to file'); - } - - if (isDefined(params.from.filename)) { - try { - const dir = params.to.folderPath; - - await mkdir(dir, { recursive: true }); - - const fileStream = await this.read({ - filePath: `${params.from.folderPath}/${params.from.filename}`, - }); - - const toPath = join( - params.to.folderPath, - params.to.filename || params.from.filename, - ); - - await pipeline(fileStream, fs.createWriteStream(toPath)); - - return; - } catch (error) { - if (error.name === 'NotFound') { - throw new FileStorageException( - 'File not found', - FileStorageExceptionCode.FILE_NOT_FOUND, - ); - } - // For other errors, throw the original error - throw error; - } - } - - const listedObjects = await this.s3Client.send( - new ListObjectsV2Command({ - Bucket: this.bucketName, - Prefix: params.from.folderPath, - }), - ); - - if (!listedObjects.Contents || listedObjects.Contents.length === 0) { - throw new Error( - `No objects found in the source folder ${params.from.folderPath}.`, - ); - } - - for (const object of listedObjects.Contents) { - const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); - - if (!isDefined(folderAndFilePaths)) { - continue; - } - - const { fromFolderPath, filename } = folderAndFilePaths; - const toFolderPath = fromFolderPath.replace( - params.from.folderPath, - params.to.folderPath, - ); - - if (!isDefined(toFolderPath)) { - continue; - } - - await this.download({ - from: { - folderPath: fromFolderPath, - filename, - }, - to: { folderPath: toFolderPath, filename }, - }); - } - } - - async checkBucketExists(args: HeadBucketCommandInput) { - try { - await this.s3Client.headBucket(args); - - return true; - } catch (error) { - if (error instanceof NotFound) { - return false; - } - - throw error; - } - } - - async createBucket(args: CreateBucketCommandInput) { - const exist = await this.checkBucketExists({ - Bucket: args.Bucket, - }); - - if (exist) { - return; - } - - return this.s3Client.createBucket(args); - } - - async checkFileExists(params: { - folderPath: string; - filename: string; - }): Promise { - try { - await this.s3Client.send( - new HeadObjectCommand({ - Bucket: this.bucketName, - Key: `${params.folderPath}/${params.filename}`, - }), - ); - } catch (error) { - if (error instanceof NotFound) { - return false; - } - - throw error; - } - - return true; - } - - async checkFolderExists(folderPath: string): Promise { - try { - const listCommand = new ListObjectsV2Command({ - Bucket: this.bucketName, - Prefix: folderPath, - MaxKeys: 1, - }); - - const result = await this.s3Client.send(listCommand); - - return (result.Contents && result.Contents.length > 0) || false; - } catch (error) { - if (error instanceof NotFound) { - return false; - } - - throw error; - } - } } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index 4cc32acb49..13206a7d96 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -1,8 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { mkdir, readdir, readFile, stat } from 'fs/promises'; +import { join } from 'path'; import { type Readable } from 'stream'; +import { isObject } from '@sniptt/guards'; import { FileFolder, Sources } from 'twenty-shared/types'; import { Like, Repository } from 'typeorm'; @@ -11,9 +14,14 @@ import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/f import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; +export type ResourceIdentifier = { + workspaceId: string; + applicationUniversalIdentifier: string; + fileFolder: FileFolder; + resourcePath: string; +}; + @Injectable() -//TODO: Implement storage driver interface when removing v1 -//export class FileStorageService implements StorageDriver { export class FileStorageService { constructor( private readonly fileStorageDriverFactory: FileStorageDriverFactory, @@ -23,10 +31,19 @@ export class FileStorageService { private readonly applicationRepository: Repository, ) {} + private buildOnStoragePath({ + workspaceId, + applicationUniversalIdentifier, + fileFolder, + resourcePath, + }: ResourceIdentifier): string { + return `${workspaceId}/${applicationUniversalIdentifier}/${fileFolder}/${resourcePath}`; + } + /** - * @deprecated Use write_v2 instead + * @deprecated Use writeFile_v2 instead */ - write(params: { + writeFile(params: { file: string | Buffer | Uint8Array; name: string; folder: string; @@ -36,29 +53,25 @@ export class FileStorageService { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - return driver.write({ + return driver.writeFile({ filePath: `${folder}/${name}`, sourceFile: file, mimeType, }); } - async write_v2({ + async writeFile_v2({ sourceFile, - destinationPath, mimeType, fileFolder, applicationUniversalIdentifier, workspaceId, + resourcePath, fileId, settings, - }: { + }: ResourceIdentifier & { sourceFile: string | Buffer | Uint8Array; - destinationPath: string; mimeType: string | undefined; - fileFolder: FileFolder; - applicationUniversalIdentifier: string; - workspaceId: string; fileId?: string; settings: FileSettings; }): Promise { @@ -71,16 +84,21 @@ export class FileStorageService { }, }); - const driverParams = { - filePath: `${workspaceId}/${applicationUniversalIdentifier}/${fileFolder}/${destinationPath}`, + const onStoragePath = this.buildOnStoragePath({ + workspaceId, + applicationUniversalIdentifier, + fileFolder, + resourcePath, + }); + + await driver.writeFile({ + filePath: onStoragePath, mimeType, sourceFile, - }; - - await driver.write(driverParams); + }); const fileEntity = await this.fileRepository.save({ - path: `${fileFolder}/${destinationPath}`, + path: `${fileFolder}/${resourcePath}`, workspaceId, applicationId: application.id, id: fileId, @@ -95,51 +113,133 @@ export class FileStorageService { } /** - * @deprecated Use read_v2 instead + * @deprecated Use readFile_v2 instead */ - read(params: { folderPath: string; filename: string }): Promise { - const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const { folderPath, filename } = params; - - return driver.read({ filePath: `${folderPath}/${filename}` }); - } - - read_v2({ - destinationPath, - fileFolder, - applicationUniversalIdentifier, - workspaceId, - }: { - destinationPath: string; - fileFolder: FileFolder; - applicationUniversalIdentifier: string; - workspaceId: string; - }): Promise { + readFile(params: { filePath: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const folderPath = `${workspaceId}/${applicationUniversalIdentifier}/${fileFolder}/${destinationPath}`; - - return driver.read({ filePath: folderPath }); + return driver.readFile(params); } - writeFolder(sources: Sources, folderPath: string): Promise { + readFile_v2(params: ResourceIdentifier): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - return driver.writeFolder(sources, folderPath); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.readFile({ filePath: onStoragePath }); } - readFolder(folderPath: string): Promise { + /** + * @deprecated Use uploadFolder_v2 with local temp directory instead + */ + async writeFolder(sources: Sources, folderPath: string): Promise { + for (const key of Object.keys(sources)) { + if (isObject(sources[key])) { + await this.writeFolder(sources[key], join(folderPath, key)); + continue; + } + await this.writeFile({ + file: sources[key], + name: key, + folder: folderPath, + mimeType: undefined, + }); + } + } + + /** + * @deprecated Use downloadFolder_v2 with local temp directory instead + */ + async readFolder( + folderPath: string, + localTempPath?: string, + ): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const tempDir = localTempPath || `/tmp/twenty-read-folder-${Date.now()}`; - return driver.readFolder(folderPath); + await mkdir(tempDir, { recursive: true }); + + await driver.downloadFolder({ + onStoragePath: folderPath, + localPath: tempDir, + }); + + return this.readLocalFolderToSources(tempDir); } + private async readLocalFolderToSources(localPath: string): Promise { + const sources: Sources = {}; + const entries = await readdir(localPath); + + for (const entry of entries) { + const entryPath = join(localPath, entry); + const stats = await stat(entryPath); + + if (stats.isFile()) { + sources[entry] = await readFile(entryPath, 'utf8'); + } else { + sources[entry] = await this.readLocalFolderToSources(entryPath); + } + } + + return sources; + } + + async readFolder_v2(params: ResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + const tempDir = `/tmp/twenty-read-folder-${Date.now()}`; + + await mkdir(tempDir, { recursive: true }); + + await driver.downloadFolder({ + onStoragePath, + localPath: tempDir, + }); + + return this.readLocalFolderToSources(tempDir); + } + + uploadFolder_v2( + params: ResourceIdentifier & { localPath: string }, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.uploadFolder({ + localPath: params.localPath, + onStoragePath, + }); + } + + downloadFolder_v2( + params: ResourceIdentifier & { localPath: string }, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.downloadFolder({ + onStoragePath, + localPath: params.localPath, + }); + } + + /** + * @deprecated Use delete_v2 instead + */ delete(params: { folderPath: string; filename?: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); return driver.delete(params); } + delete_v2(params: ResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.delete({ folderPath: onStoragePath }); + } + async deleteByFileId({ fileId, workspaceId, @@ -166,15 +266,9 @@ export class FileStorageService { await this.fileRepository.delete(fileId); } - move(params: { - from: { folderPath: string; filename?: string }; - to: { folderPath: string; filename?: string }; - }): Promise { - const driver = this.fileStorageDriverFactory.getCurrentDriver(); - - return driver.move(params); - } - + /** + * @deprecated Use copy_v2 instead + */ copy(params: { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; @@ -184,6 +278,21 @@ export class FileStorageService { return driver.copy(params); } + copy_v2({ + from, + to, + }: { + from: ResourceIdentifier; + to: ResourceIdentifier; + }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + return driver.copy({ + from: { folderPath: this.buildOnStoragePath(from) }, + to: { folderPath: this.buildOnStoragePath(to) }, + }); + } + async moveFile({ from, to, @@ -228,27 +337,62 @@ export class FileStorageService { }); } - download(params: { + /** + * @deprecated Use move_v2 instead + */ + move(params: { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - return driver.download(params); + return driver.move(params); } - checkFileExists(params: { - folderPath: string; - filename: string; - }): Promise { + move_v2({ + from, + to, + }: { + from: ResourceIdentifier; + to: ResourceIdentifier; + }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + return driver.move({ + from: { folderPath: this.buildOnStoragePath(from) }, + to: { folderPath: this.buildOnStoragePath(to) }, + }); + } + + /** + * @deprecated Use checkFileExists_v2 instead + */ + checkFileExists(params: { filePath: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); return driver.checkFileExists(params); } - checkFolderExists(folderPath: string): Promise { + checkFileExists_v2(params: ResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.checkFileExists({ filePath: onStoragePath }); + } + + /** + * @deprecated Use checkFolderExists_v2 instead + */ + checkFolderExists(params: { folderPath: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - return driver.checkFolderExists(folderPath); + return driver.checkFolderExists(params); + } + + checkFolderExists_v2(params: ResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.checkFolderExists({ folderPath: onStoragePath }); } } diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index dcce3b7d5a..ae4f283f92 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -40,7 +40,7 @@ export class FileUploadService { mimeType: string | undefined; folder: string; }) { - await this.fileStorage.write({ + await this.fileStorage.writeFile({ file, name: filename, mimeType, diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts index 9d12b73b53..a8d3212522 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts @@ -69,9 +69,9 @@ export class FilesFieldService { }, }); - return await this.fileStorageService.write_v2({ + return await this.fileStorageService.writeFile_v2({ sourceFile: sanitizedFile, - destinationPath: name, + resourcePath: name, mimeType, fileFolder: FileFolder.FilesField, applicationUniversalIdentifier: application.universalIdentifier, @@ -142,8 +142,8 @@ export class FilesFieldService { }, }); - return await this.fileStorageService.read_v2({ - destinationPath: removeFileFolderFromFileEntityPath(file.path), + return await this.fileStorageService.readFile_v2({ + resourcePath: removeFileFolderFromFileEntityPath(file.path), fileFolder: FileFolder.FilesField, applicationUniversalIdentifier: application.universalIdentifier, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index 0318cd2697..07ee8f6914 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -24,19 +24,13 @@ export const fileFolderConfigs: Record = { [FileFolder.PersonPicture]: { ignoreExpirationToken: false, }, - [FileFolder.LogicFunction]: { - ignoreExpirationToken: false, - }, - [FileFolder.LogicFunctionToDelete]: { - ignoreExpirationToken: false, - }, [FileFolder.File]: { ignoreExpirationToken: false, }, [FileFolder.AgentChat]: { ignoreExpirationToken: false, }, - [FileFolder.BuiltFunction]: { + [FileFolder.BuiltLogicFunction]: { ignoreExpirationToken: false, }, [FileFolder.BuiltFrontComponent]: { diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 37eb40cc3a..a15d5af992 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -33,9 +33,8 @@ export class FileService { ): Promise { const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`; - return await this.fileStorageService.read({ - folderPath: workspaceFolderPath, - filename, + return await this.fileStorageService.readFile({ + filePath: `${workspaceFolderPath}/${filename}`, }); } @@ -96,7 +95,9 @@ export class FileService { const workspaceFolderPath = `workspace-${workspaceId}`; const isWorkspaceFolderFound = - await this.fileStorageService.checkFolderExists(workspaceFolderPath); + await this.fileStorageService.checkFolderExists({ + folderPath: workspaceFolderPath, + }); if (!isWorkspaceFolderFound) { return; diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/check-file-path.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/check-file-path.utils.spec.ts deleted file mode 100644 index d698fc5158..0000000000 --- a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/check-file-path.utils.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FileFolder } from 'twenty-shared/types'; - -import { checkFilePath } from 'src/engine/core-modules/file/utils/check-file-path.utils'; - -describe('checkFilePath', () => { - it('should return sanitized file path', () => { - const filePath = `${FileFolder.Attachment}\0`; - const sanitizedFilePath = checkFilePath(filePath); - - expect(sanitizedFilePath).toBe(`${FileFolder.Attachment}`); - }); - - it('should return sanitized file path with size', () => { - const filePath = `${FileFolder.ProfilePicture}\0/original`; - const sanitizedFilePath = checkFilePath(filePath); - - expect(sanitizedFilePath).toBe(`${FileFolder.ProfilePicture}/original`); - }); - - it('should throw an error for invalid image size', () => { - const filePath = `${FileFolder.ProfilePicture}\0/invalid-size`; - - expect(() => checkFilePath(filePath)).toThrow( - `Size invalid-size is not allowed`, - ); - }); - - it('should throw an error for invalid folder', () => { - const filePath = `invalid-folder`; - - expect(() => checkFilePath(filePath)).toThrow( - `Folder invalid-folder is not allowed`, - ); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/check-file-path.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/check-file-path.utils.ts deleted file mode 100644 index b8064bb012..0000000000 --- a/packages/twenty-server/src/engine/core-modules/file/utils/check-file-path.utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -import { FileFolder } from 'twenty-shared/types'; - -import { type AllowedFolders } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; - -import { kebabCase } from 'src/utils/kebab-case'; -import { settings } from 'src/engine/constants/settings'; - -export const checkFilePath = (filePath: string): string => { - const allowedFolders = Object.values(FileFolder).map((value) => - kebabCase(value), - ); - - const sanitizedFilePath = filePath.replace(/\0/g, ''); - const [folder, size] = sanitizedFilePath.split('/'); - - if (!allowedFolders.includes(folder as AllowedFolders)) { - throw new BadRequestException(`Folder ${folder} is not allowed`); - } - - if ( - folder !== kebabCase(FileFolder.LogicFunction) && - size && - // @ts-expect-error legacy noImplicitAny - !settings.storage.imageCropSizes[folder]?.includes(size) - ) { - throw new BadRequestException(`Size ${size} is not allowed`); - } - - return sanitizedFilePath; -}; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface.ts index ecce70fca8..3e36aa062d 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface.ts @@ -16,17 +16,17 @@ export type LogicFunctionExecuteResult = { error?: LogicFunctionExecuteError; }; +export type LogicFunctionExecuteParams = { + flatLogicFunction: FlatLogicFunction; + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + payload: object; + env?: Record; +}; + export interface LogicFunctionExecutorDriver { delete(flatLogicFunction: FlatLogicFunction): Promise; - execute({ - flatLogicFunction, - flatLogicFunctionLayer, - payload, - env, - }: { - flatLogicFunction: FlatLogicFunction; - flatLogicFunctionLayer: FlatLogicFunctionLayer; - payload: object; - env?: Record; - }): Promise; + execute( + params: LogicFunctionExecuteParams, + ): Promise; } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/lambda.driver.ts index c5e809881d..2e6391d774 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/lambda.driver.ts @@ -19,12 +19,13 @@ import { waitUntilFunctionUpdatedV2, } from '@aws-sdk/client-lambda'; import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; -import { isDefined } from 'twenty-shared/utils'; import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { - type LogicFunctionExecutorDriver, + type LogicFunctionExecuteParams, type LogicFunctionExecuteResult, + type LogicFunctionExecutorDriver, } from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface'; import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; @@ -35,7 +36,6 @@ import { LambdaBuildDirectoryManager, NODE_LAYER_SUBFOLDER, } from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; @@ -313,30 +313,23 @@ export class LambdaDriver implements LogicFunctionExecutorDriver { async execute({ flatLogicFunction, flatLogicFunctionLayer, + applicationUniversalIdentifier, payload, env, - }: { - flatLogicFunction: FlatLogicFunction; - flatLogicFunctionLayer: FlatLogicFunctionLayer; - payload: object; - env?: Record; - }): Promise { + }: LogicFunctionExecuteParams): Promise { await this.build(flatLogicFunction, flatLogicFunctionLayer); await this.waitFunctionUpdates(flatLogicFunction); const startTime = Date.now(); - const builtHandlerFolderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.BuiltFunction, - }); - const compiledCode = ( await streamToBuffer( - await this.fileStorageService.read({ - folderPath: builtHandlerFolderPath, - filename: flatLogicFunction.builtHandlerPath, + await this.fileStorageService.readFile_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: flatLogicFunction.builtHandlerPath, }), ) ).toString('utf-8'); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/local.driver.ts index f93ab0551e..3201f7bdc6 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function-executor/drivers/local.driver.ts @@ -6,6 +6,7 @@ import { FileFolder } from 'twenty-shared/types'; import { type LogicFunctionExecutorDriver, + type LogicFunctionExecuteParams, type LogicFunctionExecuteResult, } from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface'; @@ -14,10 +15,12 @@ import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/l import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function-executor/drivers/utils/copy-and-build-dependencies'; import { ConsoleListener } from 'src/engine/core-modules/logic-function-executor/drivers/utils/intercept-console'; import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; -import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; +import { + getLogicFunctionBaseFolderPath, + getRelativePathFromBase, +} from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; export interface LocalDriverOptions { fileStorageService: FileStorageService; @@ -65,37 +68,29 @@ export class LocalDriver implements LogicFunctionExecutorDriver { async execute({ flatLogicFunction, flatLogicFunctionLayer, + applicationUniversalIdentifier, payload, env, - }: { - flatLogicFunction: FlatLogicFunction; - flatLogicFunctionLayer: FlatLogicFunctionLayer; - payload: object; - env?: Record; - }): Promise { + }: LogicFunctionExecuteParams): Promise { await this.build(flatLogicFunctionLayer); const startTime = Date.now(); - const builtHandlerFolderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.BuiltFunction, - }); - const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); try { const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init(); - await this.fileStorageService.download({ - from: { - folderPath: builtHandlerFolderPath, - filename: flatLogicFunction.builtHandlerPath, - }, - to: { - folderPath: sourceTemporaryDir, - filename: flatLogicFunction.builtHandlerPath, - }, + const baseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.builtHandlerPath, + ); + + await this.fileStorageService.downloadFolder_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: baseFolderPath, + localPath: sourceTemporaryDir, }); try { @@ -147,10 +142,11 @@ export class LocalDriver implements LogicFunctionExecutorDriver { }); try { - const builtBundleFilePath = join( - sourceTemporaryDir, + const relativeBuiltPath = getRelativePathFromBase( flatLogicFunction.builtHandlerPath, + baseFolderPath, ); + const builtBundleFilePath = join(sourceTemporaryDir, relativeBuiltPath); const runnerPath = await this.writeBootstrapRunner({ dir: sourceTemporaryDir, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function-executor/logic-function-executor.service.ts index 3732061df4..dc54d31aa6 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function-executor/logic-function-executor.service.ts @@ -2,11 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { LogicFunctionExecutorDriver, + type LogicFunctionExecuteParams, type LogicFunctionExecuteResult, } from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface'; import { LOGIC_FUNCTION_EXECUTOR_DRIVER } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.constants'; -import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; @Injectable() @@ -22,22 +22,9 @@ export class LogicFunctionExecutorService return this.driver.delete(flatLogicFunction); } - async execute({ - flatLogicFunction, - flatLogicFunctionLayer, - payload, - env, - }: { - flatLogicFunction: FlatLogicFunction; - flatLogicFunctionLayer: FlatLogicFunctionLayer; - payload: object; - env?: Record; - }): Promise { - return this.driver.execute({ - flatLogicFunction, - flatLogicFunctionLayer, - payload, - env, - }); + async execute( + params: LogicFunctionExecuteParams, + ): Promise { + return this.driver.execute(params); } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils.ts b/packages/twenty-server/src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils.ts deleted file mode 100644 index f4ceb341a4..0000000000 --- a/packages/twenty-server/src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { join } from 'path'; - -import { FileFolder } from 'twenty-shared/types'; - -import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; - -export const getLogicFunctionFolderOrThrow = ({ - flatLogicFunction, - fileFolder = FileFolder.LogicFunction, -}: { - flatLogicFunction: FlatLogicFunction; - fileFolder?: - | FileFolder.LogicFunction - | FileFolder.LogicFunctionToDelete - | FileFolder.BuiltFunction; -}) => { - return join( - 'workspace-' + flatLogicFunction.workspaceId, - fileFolder, - flatLogicFunction.id, - ); -}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index 6af3cda26d..eb0587bf57 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -353,7 +353,7 @@ export class CodeInterpreterTool implements Tool { const sanitizedFilename = path.basename(file.filename); try { - await this.fileStorageService.write({ + await this.fileStorageService.writeFile({ file: file.content, name: sanitizedFilename, mimeType: file.mimeType, @@ -402,7 +402,7 @@ export class CodeInterpreterTool implements Tool { } try { - await this.fileStorageService.write({ + await this.fileStorageService.writeFile({ file: file.content, name: sanitizedFilename, mimeType: file.mimeType, diff --git a/packages/twenty-server/src/engine/metadata-modules/function-build/function-build.service.ts b/packages/twenty-server/src/engine/metadata-modules/function-build/function-build.service.ts index 23abe82d83..1a2bfb388a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/function-build/function-build.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/function-build/function-build.service.ts @@ -1,15 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { join } from 'path'; import fs from 'fs/promises'; +import { dirname, join } from 'path'; import { build } from 'esbuild'; import { FileFolder } from 'twenty-shared/types'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; +import { + getLogicFunctionBaseFolderPath, + getRelativePathFromBase, +} from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; + +export type FunctionBuildParams = { + flatLogicFunction: FlatLogicFunction; + applicationUniversalIdentifier: string; +}; @Injectable() export class FunctionBuildService { @@ -17,58 +25,65 @@ export class FunctionBuildService { async isBuilt({ flatLogicFunction, - }: { - flatLogicFunction: FlatLogicFunction; - }): Promise { - const folderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.BuiltFunction, - }); - - return await this.fileStorageService.checkFileExists({ - folderPath, - filename: flatLogicFunction.builtHandlerPath, + applicationUniversalIdentifier, + }: FunctionBuildParams): Promise { + return await this.fileStorageService.checkFileExists_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: flatLogicFunction.builtHandlerPath, }); } async buildAndUpload({ flatLogicFunction, - }: { - flatLogicFunction: FlatLogicFunction; - }): Promise { - const sourceFolderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.LogicFunction, - }); - - const builtFolderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.BuiltFunction, - }); - + applicationUniversalIdentifier, + }: FunctionBuildParams): Promise { const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); try { const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init(); - await this.fileStorageService.download({ - from: { folderPath: sourceFolderPath }, - to: { folderPath: sourceTemporaryDir }, + const baseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.sourceHandlerPath, + ); + + await this.fileStorageService.downloadFolder_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: baseFolderPath, + localPath: sourceTemporaryDir, }); + const relativeSourcePath = getRelativePathFromBase( + flatLogicFunction.sourceHandlerPath, + baseFolderPath, + ); + const relativeBuiltPath = getRelativePathFromBase( + flatLogicFunction.builtHandlerPath, + baseFolderPath, + ); + const builtBundleFilePath = await this.buildInMemory({ sourceTemporaryDir, - sourceHandlerPath: flatLogicFunction.sourceHandlerPath, - builtHandlerPath: flatLogicFunction.builtHandlerPath, + sourceHandlerPath: relativeSourcePath, + builtHandlerPath: relativeBuiltPath, }); const builtFile = await fs.readFile(builtBundleFilePath, 'utf-8'); - await this.fileStorageService.write({ - file: builtFile, - name: flatLogicFunction.builtHandlerPath, + await this.fileStorageService.writeFile_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: flatLogicFunction.builtHandlerPath, + sourceFile: builtFile, mimeType: 'application/javascript', - folder: builtFolderPath, + settings: { + isTemporaryFile: false, + toDelete: false, + }, }); } finally { await lambdaBuildDirectoryManager.clean(); @@ -87,6 +102,8 @@ export class FunctionBuildService { const entryFilePath = join(sourceTemporaryDir, sourceHandlerPath); const builtBundleFilePath = join(sourceTemporaryDir, builtHandlerPath); + await fs.mkdir(dirname(builtBundleFilePath), { recursive: true }); + await build({ entryPoints: [entryFilePath], outfile: builtBundleFilePath, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/publish-logic-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/publish-logic-function.input.ts deleted file mode 100644 index fd263856d6..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/publish-logic-function.input.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ID, InputType } from '@nestjs/graphql'; - -import { IDField } from '@ptc-org/nestjs-query-graphql'; - -@InputType() -export class PublishLogicFunctionInput { - @IDField(() => ID, { description: 'The id of the function.' }) - id!: string; -} diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts index 735f34733c..a75b433bc8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts @@ -41,7 +41,7 @@ export enum LogicFunctionRuntime { } export const DEFAULT_SOURCE_HANDLER_PATH = 'src/index.ts'; -export const DEFAULT_BUILT_HANDLER_PATH = 'index.mjs'; +export const DEFAULT_BUILT_HANDLER_PATH = 'src/index.mjs'; export const DEFAULT_HANDLER_NAME = 'main'; @Entity('logicFunction') diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts index 86f2368dfb..d11704c22c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts @@ -16,7 +16,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat import { CreateLogicFunctionInput } from 'src/engine/metadata-modules/logic-function/dtos/create-logic-function.input'; import { ExecuteLogicFunctionInput } from 'src/engine/metadata-modules/logic-function/dtos/execute-logic-function.input'; import { GetLogicFunctionSourceCodeInput } from 'src/engine/metadata-modules/logic-function/dtos/get-logic-function-source-code.input'; -import { PublishLogicFunctionInput } from 'src/engine/metadata-modules/logic-function/dtos/publish-logic-function.input'; import { LogicFunctionExecutionResultDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; import { LogicFunctionIdInput } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-id.input'; import { LogicFunctionLogsDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-logs.dto'; @@ -210,29 +209,6 @@ export class LogicFunctionResolver { } } - @Mutation(() => LogicFunctionDTO) - @UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKFLOWS)) - async publishLogicFunction( - @Args('input') input: PublishLogicFunctionInput, - @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - ): Promise { - try { - const { id } = input; - - const flatLogicFunction = - await this.logicFunctionService.publishOneLogicFunctionOrFail( - id, - workspaceId, - ); - - return fromFlatLogicFunctionToLogicFunctionDto({ - flatLogicFunction, - }); - } catch (error) { - return logicFunctionGraphQLApiExceptionHandler(error); - } - } - @Subscription(() => LogicFunctionLogsDTO, { filter: ( payload: { logicFunctionLogs: LogicFunctionLogsDTO }, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.service.ts index db9ef95b7d..b89304ba9d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.service.ts @@ -5,6 +5,7 @@ import { DEFAULT_API_KEY_NAME, DEFAULT_API_URL_NAME, } from 'twenty-shared/application'; +import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; @@ -19,9 +20,9 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { buildEnvVar } from 'src/engine/core-modules/logic-function-executor/drivers/utils/build-env-var'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.service'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { getLogicFunctionBaseFolderPath } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { LogicFunctionLayerService } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.service'; @@ -68,25 +69,42 @@ export class LogicFunctionService { ) {} async getLogicFunctionSourceCode(workspaceId: string, id: string) { - const { flatLogicFunctionMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatLogicFunctionMaps'], - }, - ); - - const flatLogicFunction = findFlatLogicFunctionOrThrow({ - id, - flatLogicFunctionMaps, - }); - try { - const folderPath = getLogicFunctionFolderOrThrow({ - flatLogicFunction, + const { flatLogicFunctionMaps, flatApplicationMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatLogicFunctionMaps', + 'flatApplicationMaps', + ]); + + const flatLogicFunction = findFlatLogicFunctionOrThrow({ + id, + flatLogicFunctionMaps, }); - return await this.fileStorageService.readFolder(folderPath); + const applicationUniversalIdentifier = isDefined( + flatLogicFunction.applicationId, + ) + ? flatApplicationMaps.byId[flatLogicFunction.applicationId] + ?.universalIdentifier + : undefined; + + if (!isDefined(applicationUniversalIdentifier)) { + throw new LogicFunctionException( + `Application universal identifier not found for logic function ${id}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + const baseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.sourceHandlerPath, + ); + + return await this.fileStorageService.readFolder_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: baseFolderPath, + }); } catch (error) { if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) { return; @@ -166,14 +184,30 @@ export class LogicFunctionService { ...buildEnvVar(flatApplicationVariables, this.secretEncryptionService), }; + const applicationUniversalIdentifier = isDefined( + flatLogicFunction.applicationId, + ) + ? flatApplicationMaps.byId[flatLogicFunction.applicationId] + ?.universalIdentifier + : undefined; + + if (!isDefined(applicationUniversalIdentifier)) { + throw new LogicFunctionException( + `Application universal identifier not found for logic function ${flatLogicFunction.id}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + // We keep that check to build functions if ( !(await this.functionBuildService.isBuilt({ flatLogicFunction, + applicationUniversalIdentifier, })) ) { await this.functionBuildService.buildAndUpload({ flatLogicFunction, + applicationUniversalIdentifier, }); } @@ -182,6 +216,7 @@ export class LogicFunctionService { this.logicFunctionExecutorService.execute({ flatLogicFunction, flatLogicFunctionLayer, + applicationUniversalIdentifier, payload, env: envVariables, }), @@ -193,13 +228,6 @@ export class LogicFunctionService { console.log(resultLogicFunction.logs); } - const applicationUniversalIdentifier = isDefined( - flatLogicFunction.applicationId, - ) - ? flatApplicationMaps.byId[flatLogicFunction.applicationId] - ?.universalIdentifier - : undefined; - await this.subscriptionService.publish({ channel: SubscriptionChannel.LOGIC_FUNCTION_LOGS_CHANNEL, workspaceId, @@ -232,26 +260,6 @@ export class LogicFunctionService { return resultLogicFunction; } - async publishOneLogicFunctionOrFail( - id: string, - workspaceId: string, - ): Promise { - const { flatLogicFunctionMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatLogicFunctionMaps'], - }, - ); - - const existingFlatLogicFunction = findFlatLogicFunctionOrThrow({ - id, - flatLogicFunctionMaps, - }); - - return existingFlatLogicFunction; - } - async deleteOneLogicFunction({ id, workspaceId, @@ -556,6 +564,19 @@ export class LogicFunctionService { }: { id: string; workspaceId: string; + }): Promise { + return this.createLogicFunctionFromExistingLogicFunctionById({ + id, + workspaceId, + }); + } + + async createLogicFunctionFromExistingLogicFunctionById({ + id, + workspaceId, + }: { + id: string; + workspaceId: string; }): Promise { const { flatLogicFunctionMaps } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( @@ -565,32 +586,87 @@ export class LogicFunctionService { }, ); - const flatLogicFunctionToDuplicate = findFlatLogicFunctionOrThrow({ + const existingLogicFunction = findFlatLogicFunctionOrThrow({ id, flatLogicFunctionMaps, }); + return this.createLogicFunctionFromExistingLogicFunction({ + existingLogicFunction, + workspaceId, + }); + } + + async createLogicFunctionFromExistingLogicFunction({ + existingLogicFunction, + workspaceId, + }: { + existingLogicFunction: FlatLogicFunction; + workspaceId: string; + }): Promise { + const { flatApplicationMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatApplicationMaps', + ]); + + const existingApplicationUniversalIdentifier = isDefined( + existingLogicFunction.applicationId, + ) + ? flatApplicationMaps.byId[existingLogicFunction.applicationId] + ?.universalIdentifier + : undefined; + + if (!isDefined(existingApplicationUniversalIdentifier)) { + throw new LogicFunctionException( + `Application universal identifier not found for logic function ${existingLogicFunction.id}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + const newFlatLogicFunction = await this.createOneLogicFunction( { - name: flatLogicFunctionToDuplicate.name, - description: flatLogicFunctionToDuplicate.description ?? undefined, - timeoutSeconds: flatLogicFunctionToDuplicate.timeoutSeconds, - applicationId: flatLogicFunctionToDuplicate.applicationId ?? undefined, - logicFunctionLayerId: flatLogicFunctionToDuplicate.logicFunctionLayerId, + name: existingLogicFunction.name, + description: existingLogicFunction.description ?? undefined, + timeoutSeconds: existingLogicFunction.timeoutSeconds, + applicationId: existingLogicFunction.applicationId ?? undefined, + logicFunctionLayerId: existingLogicFunction.logicFunctionLayerId, }, workspaceId, ); - await this.fileStorageService.copy({ + const newApplicationUniversalIdentifier = isDefined( + newFlatLogicFunction.applicationId, + ) + ? flatApplicationMaps.byId[newFlatLogicFunction.applicationId] + ?.universalIdentifier + : undefined; + + if (!isDefined(newApplicationUniversalIdentifier)) { + throw new LogicFunctionException( + `Application universal identifier not found for logic function ${newFlatLogicFunction.id}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + const fromBaseFolderPath = getLogicFunctionBaseFolderPath( + existingLogicFunction.sourceHandlerPath, + ); + const toBaseFolderPath = getLogicFunctionBaseFolderPath( + newFlatLogicFunction.sourceHandlerPath, + ); + + await this.fileStorageService.copy_v2({ from: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction: flatLogicFunctionToDuplicate, - }), + workspaceId, + applicationUniversalIdentifier: existingApplicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: fromBaseFolderPath, }, to: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction: newFlatLogicFunction, - }), + workspaceId, + applicationUniversalIdentifier: newApplicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: toBaseFolderPath, }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-input-to-flat-logic-function.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-input-to-flat-logic-function.util.ts index d39d850025..48332cae2f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-input-to-flat-logic-function.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-input-to-flat-logic-function.util.ts @@ -12,6 +12,8 @@ import { import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; import { logicFunctionCreateHash } from 'src/engine/metadata-modules/logic-function/utils/logic-function-create-hash.utils'; +const WORKFLOW_BASE_FOLDER_PREFIX = 'workflow'; + export type FromCreateLogicFunctionInputToFlatLogicFunctionArgs = { createLogicFunctionInput: CreateLogicFunctionInput & { logicFunctionLayerId: string; @@ -28,6 +30,15 @@ export const fromCreateLogicFunctionInputToFlatLogicFunction = ({ const id = v4(); const currentDate = new Date(); + // Build full paths including the base folder + const baseFolder = `${WORKFLOW_BASE_FOLDER_PREFIX}/${id}`; + const sourceHandlerPath = + rawCreateLogicFunctionInput.sourceHandlerPath ?? + `${baseFolder}/${DEFAULT_SOURCE_HANDLER_PATH}`; + const builtHandlerPath = + rawCreateLogicFunctionInput.builtHandlerPath ?? + `${baseFolder}/${DEFAULT_BUILT_HANDLER_PATH}`; + return { id, cronTriggerSettings: null, @@ -35,14 +46,10 @@ export const fromCreateLogicFunctionInputToFlatLogicFunction = ({ httpRouteTriggerSettings: null, name: rawCreateLogicFunctionInput.name, description: rawCreateLogicFunctionInput.description ?? null, - sourceHandlerPath: - rawCreateLogicFunctionInput.sourceHandlerPath ?? - DEFAULT_SOURCE_HANDLER_PATH, + sourceHandlerPath, handlerName: rawCreateLogicFunctionInput.handlerName ?? DEFAULT_HANDLER_NAME, - builtHandlerPath: - rawCreateLogicFunctionInput.builtHandlerPath ?? - DEFAULT_BUILT_HANDLER_PATH, + builtHandlerPath, universalIdentifier: rawCreateLogicFunctionInput.universalIdentifier ?? v4(), createdAt: currentDate.toISOString(), diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util.ts new file mode 100644 index 0000000000..6ed88af8d8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util.ts @@ -0,0 +1,12 @@ +import { dirname } from 'path'; + +export const getLogicFunctionBaseFolderPath = (handlerPath: string): string => { + return dirname(dirname(handlerPath)); +}; + +export const getRelativePathFromBase = ( + handlerPath: string, + baseFolderPath: string, +): string => { + return handlerPath.replace(`${baseFolderPath}/`, ''); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts index 21419bf6e3..73f8c4aac5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts @@ -429,7 +429,7 @@ export class DevSeederDataService { const filePath = join(sampleFilesDir, filename); const fileBuffer = await readFile(filePath); - await this.fileStorageService.write({ + await this.fileStorageService.writeFile({ file: fileBuffer, name: filename, folder: `workspace-${workspaceId}/attachment`, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts index 460223fd72..7eb1eb4fd1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts @@ -1,15 +1,26 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; -import { join } from 'path'; +import { promises as fs } from 'fs'; +import { dirname, join } from 'path'; +import { Repository } from 'typeorm'; +import { isObject } from '@sniptt/guards'; +import { FileFolder, type Sources } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { getSeedProjectFiles } from 'src/engine/core-modules/logic-function-executor/drivers/utils/get-seed-project-files'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; +import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager'; +import { getLogicFunctionBaseFolderPath } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { CreateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; @@ -23,6 +34,8 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR constructor( private readonly fileStorageService: FileStorageService, private readonly functionBuildService: FunctionBuildService, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, ) { super(); } @@ -33,7 +46,13 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR const { action, queryRunner, workspaceId } = context; const { flatEntity: logicFunction } = action; - await this.buildAndSaveLogicFunction(logicFunction); + const applicationUniversalIdentifier = + await this.getApplicationUniversalIdentifier(logicFunction.applicationId); + + await this.buildAndSaveLogicFunction( + logicFunction, + applicationUniversalIdentifier, + ); const logicFunctionRepository = queryRunner.manager.getRepository( @@ -46,28 +65,84 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR }); } - private async buildAndSaveLogicFunction(logicFunction: FlatLogicFunction) { - const draftFileFolder = getLogicFunctionFolderOrThrow({ - flatLogicFunction: logicFunction, + private async getApplicationUniversalIdentifier( + applicationId: string, + ): Promise { + const application = await this.applicationRepository.findOne({ + where: { id: applicationId }, + select: ['universalIdentifier'], }); - if (isDefined(logicFunction?.code)) { - await this.fileStorageService.writeFolder( - logicFunction.code, - draftFileFolder, + if (!isDefined(application)) { + throw new LogicFunctionException( + `Application with id ${applicationId} not found`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, ); - } else { - for (const file of await getSeedProjectFiles) { - await this.fileStorageService.write({ - file: file.content, - name: file.name, - mimeType: 'application/typescript', - folder: join(draftFileFolder, file.path), - }); - } } + + return application.universalIdentifier; + } + + private async writeSourcesToLocalFolder( + sources: Sources, + localPath: string, + ): Promise { + for (const key of Object.keys(sources)) { + const filePath = join(localPath, key); + const value = sources[key]; + + if (isObject(value)) { + await this.writeSourcesToLocalFolder(value as Sources, filePath); + continue; + } + await fs.mkdir(dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, value); + } + } + + private async buildAndSaveLogicFunction( + logicFunction: FlatLogicFunction, + applicationUniversalIdentifier: string, + ) { + const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); + + try { + const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init(); + + if (isDefined(logicFunction?.code)) { + await this.writeSourcesToLocalFolder( + logicFunction.code, + sourceTemporaryDir, + ); + } else { + for (const file of await getSeedProjectFiles) { + const filePath = join(sourceTemporaryDir, file.path, file.name); + + await fs.mkdir(join(sourceTemporaryDir, file.path), { + recursive: true, + }); + await fs.writeFile(filePath, file.content); + } + } + + const baseFolderPath = getLogicFunctionBaseFolderPath( + logicFunction.sourceHandlerPath, + ); + + await this.fileStorageService.uploadFolder_v2({ + workspaceId: logicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: baseFolderPath, + localPath: sourceTemporaryDir, + }); + } finally { + await lambdaBuildDirectoryManager.clean(); + } + await this.functionBuildService.buildAndUpload({ flatLogicFunction: logicFunction, + applicationUniversalIdentifier, }); } @@ -76,10 +151,20 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR ): Promise { const { action } = context; - await this.fileStorageService.delete({ - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction: action.flatEntity, - }), + const applicationUniversalIdentifier = + await this.getApplicationUniversalIdentifier( + action.flatEntity.applicationId, + ); + + const baseFolderPath = getLogicFunctionBaseFolderPath( + action.flatEntity.sourceHandlerPath, + ); + + await this.fileStorageService.delete_v2({ + workspaceId: action.flatEntity.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: baseFolderPath, }); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts index dbd11be03c..a2cf97a0d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts @@ -5,9 +5,9 @@ import { FileFolder } from 'twenty-shared/types'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; +import { getLogicFunctionBaseFolderPath } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; import { DeleteLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; @@ -41,58 +41,19 @@ export class DeleteLogicFunctionActionHandlerService extends WorkspaceMigrationR workspaceId, }); - // TODO: Should implement a cron task or a job to delete the files after a certain period of time - await this.fileStorageService.move({ - from: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.LogicFunction, - }), - }, - to: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.LogicFunctionToDelete, - }), - }, - }); + const sourceBaseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.sourceHandlerPath, + ); + const builtBaseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.builtHandlerPath, + ); - // We can delete built code as it can be computed from source code if rollback occurs - await this.fileStorageService.delete({ - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.BuiltFunction, - }), - }); + const sourceFolderPath = `workspace-${workspaceId}/${FileFolder.Source}/${sourceBaseFolderPath}`; + const builtFolderPath = `workspace-${workspaceId}/${FileFolder.BuiltLogicFunction}/${builtBaseFolderPath}`; + + await this.fileStorageService.delete({ folderPath: sourceFolderPath }); + await this.fileStorageService.delete({ folderPath: builtFolderPath }); } - async rollbackForMetadata( - context: Omit< - WorkspaceMigrationActionRunnerArgs, - 'queryRunner' - >, - ): Promise { - const { action, allFlatEntityMaps } = context; - const { universalIdentifier } = action; - - const flatLogicFunction = findFlatEntityByUniversalIdentifierOrThrow({ - flatEntityMaps: allFlatEntityMaps.flatLogicFunctionMaps, - universalIdentifier, - }); - - await this.fileStorageService.move({ - from: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.LogicFunctionToDelete, - }), - }, - to: { - folderPath: getLogicFunctionFolderOrThrow({ - flatLogicFunction, - fileFolder: FileFolder.LogicFunction, - }), - }, - }); - } + async rollbackForMetadata(): Promise {} } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts index 7d0a1849ca..5490ce851b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts @@ -1,20 +1,32 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; -import { type Sources } from 'twenty-shared/types'; +import { promises as fs } from 'fs'; +import { dirname, join } from 'path'; + +import { isObject } from '@sniptt/guards'; +import { FileFolder, type Sources } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.service'; -import { getLogicFunctionFolderOrThrow } from 'src/engine/core-modules/logic-function-executor/utils/get-logic-function-folder-or-throw.utils'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { FunctionBuildService } from 'src/engine/metadata-modules/function-build/function-build.service'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; +import { getLogicFunctionBaseFolderPath } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util'; import { UpdateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; -import { FunctionBuildService } from 'src/engine/metadata-modules/function-build/function-build.service'; @Injectable() export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( @@ -25,6 +37,8 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR private readonly fileStorageService: FileStorageService, private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly functionBuildService: FunctionBuildService, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, ) { super(); } @@ -73,6 +87,41 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR this.logicFunctionExecutorService.delete(flatLogicFunction); } + private async getApplicationUniversalIdentifier( + applicationId: string, + ): Promise { + const application = await this.applicationRepository.findOne({ + where: { id: applicationId }, + select: ['universalIdentifier'], + }); + + if (!isDefined(application)) { + throw new LogicFunctionException( + `Application with id ${applicationId} not found`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + return application.universalIdentifier; + } + + private async writeSourcesToLocalFolder( + sources: Sources, + localPath: string, + ): Promise { + for (const key of Object.keys(sources)) { + const filePath = join(localPath, key); + const value = sources[key]; + + if (isObject(value)) { + await this.writeSourcesToLocalFolder(value as Sources, filePath); + continue; + } + await fs.mkdir(dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, value); + } + } + async handleChecksumUpdate({ flatLogicFunction, code, @@ -80,14 +129,41 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR flatLogicFunction: FlatLogicFunction; code: Sources; }) { - const fileFolder = getLogicFunctionFolderOrThrow({ - flatLogicFunction, - }); + const applicationUniversalIdentifier = + await this.getApplicationUniversalIdentifier( + flatLogicFunction.applicationId, + ); - await this.fileStorageService.writeFolder(code, fileFolder); + const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); + + try { + const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init(); + + await this.writeSourcesToLocalFolder(code, sourceTemporaryDir); + + const baseFolderPath = getLogicFunctionBaseFolderPath( + flatLogicFunction.sourceHandlerPath, + ); + + await this.fileStorageService.uploadFolder_v2({ + workspaceId: flatLogicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: baseFolderPath, + localPath: sourceTemporaryDir, + }); + } catch (error) { + this.logger.log( + 'workspace-migration-runner', + `Error updating logic function ${flatLogicFunction.id}: ${error.message}`, + ); + } finally { + await lambdaBuildDirectoryManager.clean(); + } await this.functionBuildService.buildAndUpload({ flatLogicFunction, + applicationUniversalIdentifier, }); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index a529dc137b..afb53234de 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; import { CreateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/agent/services/create-agent-action-handler.service'; import { DeleteAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/agent/services/delete-agent-action-handler.service'; @@ -70,7 +72,11 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor import { FunctionBuildModule } from 'src/engine/metadata-modules/function-build/function-build.module'; @Module({ - imports: [WorkspaceSchemaManagerModule, FunctionBuildModule], + imports: [ + TypeOrmModule.forFeature([ApplicationEntity]), + WorkspaceSchemaManagerModule, + FunctionBuildModule, + ], providers: [ CreateFieldActionHandlerService, UpdateFieldActionHandlerService, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 224ba9879e..32dfa60b3f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -918,10 +918,35 @@ export class WorkflowVersionStepOperationsWorkspaceService { async createDraftStep({ step, + workspaceId, }: { step: WorkflowAction; workspaceId: string; }): Promise { - return step; + switch (step.type) { + case WorkflowActionType.CODE: { + const newLogicFunction = + await this.logicFunctionService.createLogicFunctionFromExistingLogicFunctionById( + { + id: step.settings.input.logicFunctionId, + workspaceId, + }, + ); + + return { + ...step, + settings: { + ...step.settings, + input: { + ...step.settings.input, + logicFunctionId: newLogicFunction.id, + }, + }, + }; + } + default: { + return step; + } + } } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts deleted file mode 100644 index 589178b6aa..0000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; -import { LogicFunctionService } from 'src/engine/metadata-modules/logic-function/logic-function.service'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; -import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; -import { - WorkflowStatusesUpdateJob, - type WorkflowVersionBatchEvent, - WorkflowVersionEventType, -} from 'src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job'; - -describe('WorkflowStatusesUpdate', () => { - let job: WorkflowStatusesUpdateJob; - - const mockWorkflowRepository = { - findOneOrFail: jest.fn(), - update: jest.fn(), - }; - - const mockWorkflowVersionRepository = { - findOneOrFail: jest.fn(), - find: jest.fn(), - update: jest.fn(), - }; - - const mockGlobalWorkspaceOrmManager = { - getRepository: jest - .fn() - .mockImplementation((_workspaceId, entity, options) => { - if (!options?.shouldBypassPermissionChecks) { - throw new Error( - 'Permission check will fail because job runners dont have permissions', - ); - } - - if (entity === 'workflow') { - return Promise.resolve(mockWorkflowRepository); - } - if (entity === 'workflowVersion') { - return Promise.resolve(mockWorkflowVersionRepository); - } - - return Promise.resolve(null); - }), - executeInWorkspaceContext: jest - .fn() - - .mockImplementation((fn: () => any, _authContext?: any) => fn()), - }; - - const mockLogicFunctionService = { - publishOneLogicFunctionOrFail: jest.fn(), - findOneOrFail: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - WorkflowStatusesUpdateJob, - { - provide: GlobalWorkspaceOrmManager, - useValue: mockGlobalWorkspaceOrmManager, - }, - { - provide: LogicFunctionService, - useValue: mockLogicFunctionService, - }, - { - provide: getRepositoryToken(ObjectMetadataEntity), - useValue: { - findOneOrFail: jest.fn().mockResolvedValue({ - nameSingular: 'workflow', - }), - }, - }, - { - provide: getRepositoryToken(LogicFunctionEntity), - useValue: { - findOneOrFail: jest.fn().mockResolvedValue({ - id: 'mock-logic-function-id', - }), - }, - }, - ], - }).compile(); - - job = await module.resolve( - WorkflowStatusesUpdateJob, - ); - }); - - it('should be defined', () => { - expect(job).toBeDefined(); - }); - - describe('handle', () => { - describe('when event type is CREATE', () => { - it('when already a draft, do not change anything', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.CREATE, - workflowIds: ['1'], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.DRAFT], - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.find.mockResolvedValue([ - { status: WorkflowVersionStatus.DRAFT }, - ]); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect(mockWorkflowRepository.update).toHaveBeenCalledTimes(0); - }); - - it('when no draft yet, update statuses', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.CREATE, - workflowIds: ['1'], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.ACTIVE], - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.find.mockResolvedValue([ - { status: WorkflowVersionStatus.ACTIVE }, - { status: WorkflowVersionStatus.DRAFT }, - ]); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect(mockWorkflowRepository.update).toHaveBeenCalledWith( - { id: '1' }, - { statuses: [WorkflowStatus.DRAFT, WorkflowStatus.ACTIVE] }, - ); - }); - }); - - describe('when event type is STATUS_UPDATE', () => { - test('when status is the same, should not do anything', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.STATUS_UPDATE, - statusUpdates: [ - { - workflowId: '1', - workflowVersionId: '1', - previousStatus: WorkflowVersionStatus.ACTIVE, - newStatus: WorkflowVersionStatus.ACTIVE, - }, - ], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.ACTIVE], - }; - - const mockWorkflowVersion = { - id: '1', - status: WorkflowVersionStatus.ACTIVE, - steps: [], - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.findOneOrFail.mockResolvedValue( - mockWorkflowVersion, - ); - mockWorkflowVersionRepository.find.mockResolvedValue([ - { status: WorkflowVersionStatus.ACTIVE }, - ]); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect( - mockWorkflowVersionRepository.findOneOrFail, - ).toHaveBeenCalledTimes(1); - expect(mockWorkflowRepository.update).toHaveBeenCalledTimes(0); - }); - - test('when WorkflowVersionStatus.DRAFT to WorkflowVersionStatus.ACTIVE, should activate and publish logic functions', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.STATUS_UPDATE, - statusUpdates: [ - { - workflowId: '1', - workflowVersionId: '1', - previousStatus: WorkflowVersionStatus.DRAFT, - newStatus: WorkflowVersionStatus.ACTIVE, - }, - ], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.DRAFT], - }; - - const mockWorkflowVersion = { - id: '1', - status: WorkflowVersionStatus.ACTIVE, - steps: [ - { - type: 'CODE', - settings: { - input: { - logicFunctionId: 'logic-function-1', - }, - }, - }, - ], - }; - - const mockLogicFunction = { - id: 'logic-function-1', - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.findOneOrFail.mockResolvedValue( - mockWorkflowVersion, - ); - mockWorkflowVersionRepository.find.mockResolvedValue([ - { status: WorkflowVersionStatus.ACTIVE }, - ]); - mockLogicFunctionService.findOneOrFail.mockResolvedValue( - mockLogicFunction, - ); - mockLogicFunctionService.publishOneLogicFunctionOrFail.mockResolvedValue( - mockLogicFunction, - ); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect( - mockWorkflowVersionRepository.findOneOrFail, - ).toHaveBeenCalledTimes(1); - expect( - mockLogicFunctionService.publishOneLogicFunctionOrFail, - ).toHaveBeenCalledWith('logic-function-1', '1'); - expect(mockWorkflowVersionRepository.update).toHaveBeenCalledWith('1', { - steps: [ - { - type: 'CODE', - settings: { - input: { - logicFunctionId: 'logic-function-1', - }, - }, - }, - ], - }); - expect(mockWorkflowRepository.update).toHaveBeenCalledWith( - { id: '1' }, - { statuses: [WorkflowStatus.ACTIVE] }, - ); - }); - }); - - describe('when event type is DELETE', () => { - test('when status is not draft, should not do anything', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.DELETE, - workflowIds: ['1'], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.ACTIVE], - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.find.mockResolvedValue([ - { status: WorkflowVersionStatus.ACTIVE }, - ]); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect(mockWorkflowRepository.update).toHaveBeenCalledTimes(0); - }); - - test('when status is draft, should delete', async () => { - const event: WorkflowVersionBatchEvent = { - workspaceId: '1', - type: WorkflowVersionEventType.DELETE, - workflowIds: ['1'], - }; - - const mockWorkflow = { - id: '1', - statuses: [WorkflowStatus.DRAFT], - }; - - mockWorkflowRepository.findOneOrFail.mockResolvedValue(mockWorkflow); - mockWorkflowVersionRepository.find.mockResolvedValue([]); - - await job.handle(event); - - expect(mockWorkflowRepository.findOneOrFail).toHaveBeenCalledTimes(1); - expect(mockWorkflowRepository.update).toHaveBeenCalledWith( - { id: '1' }, - { statuses: [] }, - ); - }); - }); - }); -}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts index dde368ba5c..fc51af51e2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts @@ -1,13 +1,11 @@ import { Logger, Scope } from '@nestjs/common'; import isEqual from 'lodash.isequal'; -import { isDefined } from 'twenty-shared/utils'; import { In } from 'typeorm'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; -import { LogicFunctionService } from 'src/engine/metadata-modules/logic-function/logic-function.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -19,10 +17,6 @@ import { WorkflowStatus, type WorkflowWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; export enum WorkflowVersionEventType { CREATE = 'CREATE', @@ -67,7 +61,6 @@ export class WorkflowStatusesUpdateJob { constructor( private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, - private readonly logicFunctionService: LogicFunctionService, ) {} @Process(WorkflowStatusesUpdateJob.name) @@ -150,46 +143,6 @@ export class WorkflowStatusesUpdateJob { ); } - private async handlePublishLogicFunction({ - statusUpdate, - workspaceId, - workflowVersion, - workflowVersionRepository, - }: { - statusUpdate: WorkflowVersionStatusUpdate; - workspaceId: string; - workflowVersion: WorkflowVersionWorkspaceEntity; - workflowVersionRepository: WorkspaceRepository; - }) { - const shouldComputeNewSteps = - statusUpdate.newStatus === WorkflowVersionStatus.ACTIVE && - isDefined(workflowVersion.steps) && - workflowVersion.steps.filter( - (step) => step.type === WorkflowActionType.CODE, - ).length > 0; - - if (shouldComputeNewSteps) { - const newSteps: WorkflowAction[] = []; - - for (const step of workflowVersion.steps || []) { - const newStep = { ...step }; - - if (step.type === WorkflowActionType.CODE) { - await this.logicFunctionService.publishOneLogicFunctionOrFail( - step.settings.input.logicFunctionId, - workspaceId, - ); - } - - newSteps.push(newStep); - } - - await workflowVersionRepository.update(statusUpdate.workflowVersionId, { - steps: newSteps, - }); - } - } - private async handleWorkflowVersionStatusUpdated({ statusUpdate, workspaceId, @@ -217,17 +170,6 @@ export class WorkflowStatusesUpdateJob { }, }); - const workflowVersion = await workflowVersionRepository.findOneOrFail({ - where: { id: statusUpdate.workflowVersionId }, - }); - - await this.handlePublishLogicFunction({ - workflowVersion, - workflowVersionRepository, - workspaceId, - statusUpdate, - }); - const newWorkflowStatuses = await this.getWorkflowStatuses({ workflowId: statusUpdate.workflowId, workflowVersionRepository, diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts index ee5ee69313..c49d5ea6c5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts @@ -1,7 +1,6 @@ import { createOneLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/create-one-logic-function.util'; import { deleteLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/delete-logic-function.util'; import { executeLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/execute-logic-function.util'; -import { publishLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/publish-logic-function.util'; import { updateLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/update-logic-function.util'; import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; @@ -60,14 +59,6 @@ describe('Logic Function Execution', () => { expect(functionId).toBeDefined(); createdFunctionIds.push(functionId); - // Publish the function - const { data: publishData } = await publishLogicFunction({ - input: { id: functionId }, - expectToFail: false, - }); - - expect(publishData?.publishLogicFunction?.id).toBeDefined(); - // Execute with the default template's expected params: { a: string, b: number } const { data: executeData } = await executeLogicFunction({ input: { @@ -116,12 +107,6 @@ describe('Logic Function Execution', () => { expectToFail: false, }); - // Publish the function - await publishLogicFunction({ - input: { id: functionId }, - expectToFail: false, - }); - // Execute the function with items to group const { data: executeData } = await executeLogicFunction({ input: { @@ -188,12 +173,6 @@ describe('Logic Function Execution', () => { expectToFail: false, }); - // Publish the function - await publishLogicFunction({ - input: { id: functionId }, - expectToFail: false, - }); - // Execute with shouldFail = false (should succeed) const { data: successData } = await executeLogicFunction({ input: { diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function-query-factory.util.ts deleted file mode 100644 index 8dc7e33b72..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function-query-factory.util.ts +++ /dev/null @@ -1,29 +0,0 @@ -import gql from 'graphql-tag'; - -export type PublishLogicFunctionFactoryInput = { - id: string; -}; - -const DEFAULT_LOGIC_FUNCTION_GQL_FIELDS = ` - id - name -`; - -export const publishLogicFunctionQueryFactory = ({ - input, - gqlFields = DEFAULT_LOGIC_FUNCTION_GQL_FIELDS, -}: { - input: PublishLogicFunctionFactoryInput; - gqlFields?: string; -}) => ({ - query: gql` - mutation PublishLogicFunction($input: PublishLogicFunctionInput!) { - publishLogicFunction(input: $input) { - ${gqlFields} - } - } - `, - variables: { - input, - }, -}); diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function.util.ts deleted file mode 100644 index a2971d6f76..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/utils/publish-logic-function.util.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - type PublishLogicFunctionFactoryInput, - publishLogicFunctionQueryFactory, -} from 'test/integration/metadata/suites/logic-function/utils/publish-logic-function-query-factory.util'; -import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; -import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; -import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; -import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; - -import { type LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto'; - -export const publishLogicFunction = async ({ - input, - gqlFields, - expectToFail = false, - token, -}: { - input: PublishLogicFunctionFactoryInput; - gqlFields?: string; - expectToFail?: boolean; - token?: string; -}): CommonResponseBody<{ - publishLogicFunction: LogicFunctionDTO; -}> => { - const graphqlOperation = publishLogicFunctionQueryFactory({ - input, - gqlFields, - }); - - const response = await makeMetadataAPIRequest(graphqlOperation, token); - - if (expectToFail === true) { - warnIfNoErrorButExpectedToFail({ - response, - errorMessage: 'Logic Function publish should have failed but did not', - }); - } - - if (expectToFail === false) { - warnIfErrorButNotExpectedToFail({ - response, - errorMessage: 'Logic Function publish has failed but should not', - }); - } - - return { data: response.body.data, errors: response.body.errors }; -}; diff --git a/packages/twenty-shared/src/types/FileFolder.ts b/packages/twenty-shared/src/types/FileFolder.ts index 42f766f0be..52bddc2976 100644 --- a/packages/twenty-shared/src/types/FileFolder.ts +++ b/packages/twenty-shared/src/types/FileFolder.ts @@ -3,11 +3,9 @@ export enum FileFolder { WorkspaceLogo = 'workspace-logo', Attachment = 'attachment', PersonPicture = 'person-picture', - LogicFunction = 'logic-function', - LogicFunctionToDelete = 'logic-function-to-delete', File = 'file', AgentChat = 'agent-chat', - BuiltFunction = 'built-function', + BuiltLogicFunction = 'built-logic-function', BuiltFrontComponent = 'built-front-component', PublicAsset = 'public-asset', Source = 'source',