Rework logic function module (#17588)
core-modules/logic-function/
├── logic-function.module.ts
├── logic-function-executor/
│ ├── logic-function-executor.module.ts
│ ├── commands/
│ │ └── add-packages.command.ts
│ ├── constants/
│ │ └── logic-function-executor.constants.ts
│ ├── factories/
│ │ └── logic-function-module.factory.ts
│ ├── interfaces/
│ │ └── logic-function-executor.interface.ts
│ └── services/
│ └── logic-function-executor.service.ts
├── logic-function-build/
│ ├── logic-function-build.module.ts
│ ├── services/
│ │ └── logic-function-build.service.ts
│ └── utils/
│ └── get-logic-function-base-folder-path.util.ts
├── logic-function-drivers/
│ ├── logic-function-drivers.module.ts
│ ├── constants/
│ │ └── ...
│ ├── drivers/
│ │ ├── disabled.driver.ts
│ │ ├── lambda.driver.ts
│ │ └── local.driver.ts
│ ├── interfaces/
│ │ └── logic-function-executor-driver.interface.ts
│ ├── layers/
│ │ └── ...
│ └── utils/
│ └── ...
├── logic-function-layer/
│ ├── logic-function-layer.module.ts
│ └── services/
│ └── logic-function-layer.service.ts
└── logic-function-trigger/
├── logic-function-trigger.module.ts
├── jobs/
│ └── logic-function-trigger.job.ts
└── triggers/
├── cron/
├── database-event/
└── route/
├── exceptions/
├── services/
│ └── route-trigger.service.ts
└── utils/
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@ import { FieldPermissionService } from 'src/engine/metadata-modules/object-permi
|
||||
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
|
||||
import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { LogicFunctionLayerService } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.service';
|
||||
import { LogicFunctionLayerService } from 'src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service';
|
||||
import {
|
||||
CronTriggerSettings,
|
||||
DatabaseEventTriggerSettings,
|
||||
|
||||
@@ -47,8 +47,8 @@ import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { SearchModule } from 'src/engine/core-modules/search/search.module';
|
||||
import { logicFunctionExecutorModuleFactory } from 'src/engine/core-modules/logic-function-executor/logic-function-executor-module.factory';
|
||||
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.module';
|
||||
import { logicFunctionModuleFactory } from 'src/engine/core-modules/logic-function/logic-function-executor/factories/logic-function-module.factory';
|
||||
import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
@@ -141,8 +141,8 @@ import { FileModule } from './file/file.module';
|
||||
CacheStorageModule,
|
||||
AiModelsModule,
|
||||
AiBillingModule,
|
||||
LogicFunctionExecutorModule.forRootAsync({
|
||||
useFactory: logicFunctionExecutorModuleFactory,
|
||||
LogicFunctionModule.forRootAsync({
|
||||
useFactory: logicFunctionModuleFactory,
|
||||
inject: [TwentyConfigService, FileStorageService],
|
||||
}),
|
||||
CodeInterpreterModule.forRootAsync({
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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 FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
|
||||
@Injectable()
|
||||
export class LogicFunctionExecutorService
|
||||
implements LogicFunctionExecutorDriver
|
||||
{
|
||||
constructor(
|
||||
@Inject(LOGIC_FUNCTION_EXECUTOR_DRIVER)
|
||||
private driver: LogicFunctionExecutorDriver,
|
||||
) {}
|
||||
|
||||
async delete(flatLogicFunction: FlatLogicFunction): Promise<void> {
|
||||
return this.driver.delete(flatLogicFunction);
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: LogicFunctionExecuteParams,
|
||||
): Promise<LogicFunctionExecuteResult> {
|
||||
return this.driver.execute(params);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogicFunctionBuildService } from 'src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service';
|
||||
|
||||
@Module({
|
||||
providers: [LogicFunctionBuildService],
|
||||
exports: [LogicFunctionBuildService],
|
||||
})
|
||||
export class LogicFunctionBuildModule {}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
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/logic-function-drivers/utils/lambda-build-directory-manager';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import {
|
||||
getLogicFunctionBaseFolderPath,
|
||||
getRelativePathFromBase,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util';
|
||||
|
||||
export type FunctionBuildParams = {
|
||||
flatLogicFunction: FlatLogicFunction;
|
||||
applicationUniversalIdentifier: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LogicFunctionBuildService {
|
||||
constructor(private readonly fileStorageService: FileStorageService) {}
|
||||
|
||||
async isBuilt({
|
||||
flatLogicFunction,
|
||||
applicationUniversalIdentifier,
|
||||
}: FunctionBuildParams): Promise<boolean> {
|
||||
return await this.fileStorageService.checkFileExists_v2({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: flatLogicFunction.builtHandlerPath,
|
||||
});
|
||||
}
|
||||
|
||||
async buildAndUpload({
|
||||
flatLogicFunction,
|
||||
applicationUniversalIdentifier,
|
||||
}: FunctionBuildParams): Promise<void> {
|
||||
const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager();
|
||||
|
||||
try {
|
||||
const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init();
|
||||
|
||||
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: relativeSourcePath,
|
||||
builtHandlerPath: relativeBuiltPath,
|
||||
});
|
||||
|
||||
const builtFile = await fs.readFile(builtBundleFilePath, 'utf-8');
|
||||
|
||||
await this.fileStorageService.writeFile_v2({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: flatLogicFunction.builtHandlerPath,
|
||||
sourceFile: builtFile,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await lambdaBuildDirectoryManager.clean();
|
||||
}
|
||||
}
|
||||
|
||||
private async buildInMemory({
|
||||
sourceTemporaryDir,
|
||||
sourceHandlerPath,
|
||||
builtHandlerPath,
|
||||
}: {
|
||||
sourceTemporaryDir: string;
|
||||
sourceHandlerPath: string;
|
||||
builtHandlerPath: string;
|
||||
}): Promise<string> {
|
||||
const entryFilePath = join(sourceTemporaryDir, sourceHandlerPath);
|
||||
const builtBundleFilePath = join(sourceTemporaryDir, builtHandlerPath);
|
||||
|
||||
await fs.mkdir(dirname(builtBundleFilePath), { recursive: true });
|
||||
|
||||
await build({
|
||||
entryPoints: [entryFilePath],
|
||||
outfile: builtBundleFilePath,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'es2017',
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
packages: 'external',
|
||||
});
|
||||
|
||||
return builtBundleFilePath;
|
||||
}
|
||||
}
|
||||
+12
@@ -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}/`, '');
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type LogicFunctionExecutorDriver,
|
||||
type LogicFunctionExecuteResult,
|
||||
} from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface';
|
||||
|
||||
import {
|
||||
LogicFunctionException,
|
||||
+5
-5
@@ -26,16 +26,16 @@ import {
|
||||
type LogicFunctionExecuteParams,
|
||||
type LogicFunctionExecuteResult,
|
||||
type LogicFunctionExecutorDriver,
|
||||
} from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface';
|
||||
|
||||
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function-executor/drivers/utils/copy-and-build-dependencies';
|
||||
import { copyExecutor } from 'src/engine/core-modules/logic-function-executor/drivers/utils/copy-executor';
|
||||
import { createZipFile } from 'src/engine/core-modules/logic-function-executor/drivers/utils/create-zip-file';
|
||||
import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies';
|
||||
import { copyExecutor } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-executor';
|
||||
import { createZipFile } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/create-zip-file';
|
||||
import {
|
||||
LambdaBuildDirectoryManager,
|
||||
NODE_LAYER_SUBFOLDER,
|
||||
} from 'src/engine/core-modules/logic-function-executor/drivers/utils/lambda-build-directory-manager';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager';
|
||||
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';
|
||||
+6
-6
@@ -8,19 +8,19 @@ import {
|
||||
type LogicFunctionExecutorDriver,
|
||||
type LogicFunctionExecuteParams,
|
||||
type LogicFunctionExecuteResult,
|
||||
} from 'src/engine/core-modules/logic-function-executor/drivers/interfaces/logic-function-executor-driver.interface';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface';
|
||||
|
||||
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function-executor/drivers/constants/logic-function-executor-tmpdir-folder';
|
||||
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 { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
|
||||
import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies';
|
||||
import { ConsoleListener } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/intercept-console';
|
||||
import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager';
|
||||
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 {
|
||||
getLogicFunctionBaseFolderPath,
|
||||
getRelativePathFromBase,
|
||||
} from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-base-folder-path.util';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util';
|
||||
|
||||
export interface LocalDriverOptions {
|
||||
fileStorageService: FileStorageService;
|
||||
+12
-13
@@ -1,18 +1,17 @@
|
||||
import { type DynamicModule, Global } from '@nestjs/common';
|
||||
import { type DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { AddPackagesCommand } from 'src/engine/core-modules/logic-function-executor/commands/add-packages.command';
|
||||
import { DisabledDriver } from 'src/engine/core-modules/logic-function-executor/drivers/disabled.driver';
|
||||
import { LambdaDriver } from 'src/engine/core-modules/logic-function-executor/drivers/lambda.driver';
|
||||
import { LocalDriver } from 'src/engine/core-modules/logic-function-executor/drivers/local.driver';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_DRIVER } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.constants';
|
||||
import {
|
||||
LogicFunctionExecutorDriverType,
|
||||
type LogicFunctionExecutorModuleAsyncOptions,
|
||||
} from 'src/engine/core-modules/logic-function-executor/logic-function-executor.interface';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.service';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/interfaces/logic-function-executor.interface';
|
||||
|
||||
@Global()
|
||||
export class LogicFunctionExecutorModule {
|
||||
import { DisabledDriver } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/disabled.driver';
|
||||
import { LambdaDriver } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver';
|
||||
import { LocalDriver } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_DRIVER } from 'src/engine/core-modules/logic-function/logic-function-executor/constants/logic-function-executor.constants';
|
||||
|
||||
@Module({})
|
||||
export class LogicFunctionDriversModule {
|
||||
static forRootAsync(
|
||||
options: LogicFunctionExecutorModuleAsyncOptions,
|
||||
): DynamicModule {
|
||||
@@ -42,10 +41,10 @@ export class LogicFunctionExecutorModule {
|
||||
};
|
||||
|
||||
return {
|
||||
module: LogicFunctionExecutorModule,
|
||||
module: LogicFunctionDriversModule,
|
||||
imports: options.imports || [],
|
||||
providers: [LogicFunctionExecutorService, provider, AddPackagesCommand],
|
||||
exports: [LogicFunctionExecutorService],
|
||||
providers: [provider],
|
||||
exports: [LOGIC_FUNCTION_EXECUTOR_DRIVER],
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import { type 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 { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/build-env-var';
|
||||
|
||||
describe('buildEnvVar', () => {
|
||||
const mockSecretEncryptionService = {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { promises as fs, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function-executor/drivers/utils/get-layer-dependencies-dir-name';
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-layer-dependencies-dir-name';
|
||||
import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type';
|
||||
|
||||
const execFilePromise = promisify(execFile);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
import { getExecutorFilePath } from 'src/engine/core-modules/logic-function-executor/drivers/utils/get-executor-file-path';
|
||||
import { getExecutorFilePath } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-executor-file-path';
|
||||
|
||||
export const copyExecutor = async (buildDirectory: string) => {
|
||||
await fs.mkdir(buildDirectory, {
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { ASSET_PATH } from 'src/constants/assets-path';
|
||||
export const getExecutorFilePath = (): string => {
|
||||
const baseTypescriptProjectPath = path.join(
|
||||
ASSET_PATH,
|
||||
`engine/core-modules/logic-function-executor/drivers/constants/executor`,
|
||||
`engine/core-modules/logic-function/logic-function-drivers/constants/executor`,
|
||||
);
|
||||
|
||||
return path.resolve(__dirname, baseTypescriptProjectPath);
|
||||
+2
-2
@@ -3,8 +3,8 @@ import { join } from 'path';
|
||||
|
||||
import { type PackageJson } from 'twenty-shared/application';
|
||||
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function-executor/drivers/utils/get-layer-dependencies-dir-name';
|
||||
import { LAST_LAYER_VERSION } from 'src/engine/core-modules/logic-function-executor/drivers/layers/last-layer-version';
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-layer-dependencies-dir-name';
|
||||
import { LAST_LAYER_VERSION } from 'src/engine/core-modules/logic-function/logic-function-drivers/layers/last-layer-version';
|
||||
|
||||
export type LayerDependencies = {
|
||||
packageJson: PackageJson;
|
||||
+1
-1
@@ -7,7 +7,7 @@ export const getLayerDependenciesDirName = (
|
||||
): string => {
|
||||
const baseTypescriptProjectPath = path.join(
|
||||
ASSET_PATH,
|
||||
`engine/core-modules/logic-function-executor/drivers/layers/${version}`,
|
||||
`engine/core-modules/logic-function/logic-function-drivers/layers/${version}`,
|
||||
);
|
||||
|
||||
return path.resolve(__dirname, baseTypescriptProjectPath);
|
||||
+1
-1
@@ -32,7 +32,7 @@ const getAllFiles = async (
|
||||
export const getSeedProjectFiles = (async () => {
|
||||
const seedProjectPath = join(
|
||||
ASSET_PATH,
|
||||
`engine/core-modules/logic-function-executor/drivers/constants/seed-project`,
|
||||
`engine/core-modules/logic-function/logic-function-drivers/constants/seed-project`,
|
||||
);
|
||||
|
||||
return await getAllFiles(seedProjectPath);
|
||||
+1
-1
@@ -3,7 +3,7 @@ import * as fs from 'fs/promises';
|
||||
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function-executor/drivers/constants/logic-function-executor-tmpdir-folder';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
|
||||
|
||||
export const NODE_LAYER_SUBFOLDER = 'nodejs';
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ export class AddPackagesCommand extends CommandRunner {
|
||||
this.logger.log('');
|
||||
|
||||
const layersFolder = this.getAbsoluteFilePath(
|
||||
`src/engine/core-modules/logic-function-executor/drivers/layers`,
|
||||
`src/engine/core-modules/logic-function/logic-function-drivers/layers`,
|
||||
);
|
||||
|
||||
const currentVersion = await this.getLastLayerVersion();
|
||||
@@ -108,7 +108,7 @@ export class AddPackagesCommand extends CommandRunner {
|
||||
|
||||
private async getLastLayerVersion() {
|
||||
const filePath = this.getAbsoluteFilePath(
|
||||
'src/engine/core-modules/logic-function-executor/drivers/layers/last-layer-version.ts',
|
||||
'src/engine/core-modules/logic-function/logic-function-drivers/layers/last-layer-version.ts',
|
||||
);
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
@@ -123,7 +123,7 @@ export class AddPackagesCommand extends CommandRunner {
|
||||
|
||||
private async updateLastLayerVersion(newVersion: number) {
|
||||
const filePath = this.getAbsoluteFilePath(
|
||||
'src/engine/core-modules/logic-function-executor/drivers/layers/last-layer-version.ts',
|
||||
'src/engine/core-modules/logic-function/logic-function-drivers/layers/last-layer-version.ts',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
+4
-3
@@ -1,13 +1,14 @@
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
|
||||
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import {
|
||||
LogicFunctionExecutorDriverType,
|
||||
type LogicFunctionExecutorModuleOptions,
|
||||
} from 'src/engine/core-modules/logic-function-executor/logic-function-executor.interface';
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/interfaces/logic-function-executor.interface';
|
||||
|
||||
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export const logicFunctionExecutorModuleFactory = async (
|
||||
export const logicFunctionModuleFactory = async (
|
||||
twentyConfigService: TwentyConfigService,
|
||||
fileStorageService: FileStorageService,
|
||||
): Promise<LogicFunctionExecutorModuleOptions> => {
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { type FactoryProvider, type ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function-executor/drivers/lambda.driver';
|
||||
import { type LocalDriverOptions } from 'src/engine/core-modules/logic-function-executor/drivers/local.driver';
|
||||
import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver';
|
||||
import { type LocalDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver';
|
||||
|
||||
export enum LogicFunctionExecutorDriverType {
|
||||
DISABLED = 'DISABLED',
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { LogicFunctionBuildModule } from 'src/engine/core-modules/logic-function/logic-function-build/logic-function-build.module';
|
||||
import { AddPackagesCommand } from 'src/engine/core-modules/logic-function/logic-function-executor/commands/add-packages.command';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
TokenModule,
|
||||
SecretEncryptionModule,
|
||||
SubscriptionsModule,
|
||||
WorkspaceCacheModule,
|
||||
LogicFunctionBuildModule,
|
||||
],
|
||||
providers: [LogicFunctionExecutorService, AddPackagesCommand],
|
||||
exports: [LogicFunctionExecutorService],
|
||||
})
|
||||
export class LogicFunctionExecutorModule {}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
DEFAULT_API_KEY_NAME,
|
||||
DEFAULT_API_URL_NAME,
|
||||
} from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
LogicFunctionExecutorDriver,
|
||||
type LogicFunctionExecuteParams,
|
||||
type LogicFunctionExecuteResult,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { LogicFunctionBuildService } from 'src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/build-env-var';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_DRIVER } from 'src/engine/core-modules/logic-function/logic-function-executor/constants/logic-function-executor.constants';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { cleanServerUrl } from 'src/utils/clean-server-url';
|
||||
|
||||
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
|
||||
|
||||
export class LogicFunctionExecutionException extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: LogicFunctionExecutionExceptionCode,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'LogicFunctionExecutionException';
|
||||
}
|
||||
}
|
||||
|
||||
export enum LogicFunctionExecutionExceptionCode {
|
||||
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LogicFunctionExecutorService
|
||||
implements LogicFunctionExecutorDriver
|
||||
{
|
||||
constructor(
|
||||
@Inject(LOGIC_FUNCTION_EXECUTOR_DRIVER)
|
||||
private driver: LogicFunctionExecutorDriver,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly secretEncryptionService: SecretEncryptionService,
|
||||
private readonly functionBuildService: LogicFunctionBuildService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async delete(flatLogicFunction: FlatLogicFunction): Promise<void> {
|
||||
return this.driver.delete(flatLogicFunction);
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: LogicFunctionExecuteParams,
|
||||
): Promise<LogicFunctionExecuteResult> {
|
||||
return this.driver.execute(params);
|
||||
}
|
||||
|
||||
async executeOneLogicFunction({
|
||||
id,
|
||||
workspaceId,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
payload: object;
|
||||
}): Promise<LogicFunctionExecuteResult> {
|
||||
await this.throttleExecution(workspaceId);
|
||||
|
||||
const {
|
||||
flatLogicFunctionMaps,
|
||||
flatApplicationMaps,
|
||||
applicationVariableMaps,
|
||||
logicFunctionLayerMaps,
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatLogicFunctionMaps',
|
||||
'flatApplicationMaps',
|
||||
'applicationVariableMaps',
|
||||
'logicFunctionLayerMaps',
|
||||
]);
|
||||
|
||||
const flatLogicFunction = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: id,
|
||||
flatEntityMaps: flatLogicFunctionMaps,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(flatLogicFunction) ||
|
||||
isDefined(flatLogicFunction.deletedAt)
|
||||
) {
|
||||
throw new LogicFunctionExecutionException(
|
||||
`Logic function with id ${id} not found`,
|
||||
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const flatLogicFunctionLayer =
|
||||
logicFunctionLayerMaps.byId[flatLogicFunction.logicFunctionLayerId];
|
||||
|
||||
if (!isDefined(flatLogicFunctionLayer)) {
|
||||
throw new LogicFunctionExecutionException(
|
||||
`Logic function layer with id ${flatLogicFunction.logicFunctionLayerId} not found`,
|
||||
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const applicationAccessToken = isDefined(flatLogicFunction.applicationId)
|
||||
? await this.applicationTokenService.generateApplicationToken({
|
||||
workspaceId,
|
||||
applicationId: flatLogicFunction.applicationId,
|
||||
expiresInSeconds: Math.max(
|
||||
flatLogicFunction.timeoutSeconds,
|
||||
MIN_TOKEN_EXPIRATION_IN_SECONDS,
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const baseUrl = cleanServerUrl(this.twentyConfigService.get('SERVER_URL'));
|
||||
|
||||
const flatApplicationVariables = isDefined(flatLogicFunction.applicationId)
|
||||
? (applicationVariableMaps.byApplicationId[
|
||||
flatLogicFunction.applicationId
|
||||
] ?? [])
|
||||
: [];
|
||||
|
||||
const envVariables = {
|
||||
...(isDefined(baseUrl)
|
||||
? {
|
||||
[DEFAULT_API_URL_NAME]: baseUrl,
|
||||
}
|
||||
: {}),
|
||||
...(isDefined(applicationAccessToken)
|
||||
? {
|
||||
[DEFAULT_API_KEY_NAME]: applicationAccessToken.token,
|
||||
}
|
||||
: {}),
|
||||
...buildEnvVar(flatApplicationVariables, this.secretEncryptionService),
|
||||
};
|
||||
|
||||
const applicationUniversalIdentifier = isDefined(
|
||||
flatLogicFunction.applicationId,
|
||||
)
|
||||
? flatApplicationMaps.byId[flatLogicFunction.applicationId]
|
||||
?.universalIdentifier
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(applicationUniversalIdentifier)) {
|
||||
throw new LogicFunctionExecutionException(
|
||||
`Application universal identifier not found for logic function ${flatLogicFunction.id}`,
|
||||
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this.functionBuildService.isBuilt({
|
||||
flatLogicFunction,
|
||||
applicationUniversalIdentifier,
|
||||
}))
|
||||
) {
|
||||
await this.functionBuildService.buildAndUpload({
|
||||
flatLogicFunction,
|
||||
applicationUniversalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
const resultLogicFunction = await this.callWithTimeout({
|
||||
callback: () =>
|
||||
this.execute({
|
||||
flatLogicFunction,
|
||||
flatLogicFunctionLayer,
|
||||
applicationUniversalIdentifier,
|
||||
payload,
|
||||
env: envVariables,
|
||||
}),
|
||||
timeoutMs: flatLogicFunction.timeoutSeconds * 1000,
|
||||
});
|
||||
|
||||
if (this.twentyConfigService.get('LOGIC_FUNCTION_LOGS_ENABLED')) {
|
||||
/* eslint-disable no-console */
|
||||
console.log(resultLogicFunction.logs);
|
||||
}
|
||||
|
||||
await this.subscriptionService.publish({
|
||||
channel: SubscriptionChannel.LOGIC_FUNCTION_LOGS_CHANNEL,
|
||||
workspaceId,
|
||||
payload: {
|
||||
logicFunctionLogs: {
|
||||
logs: resultLogicFunction.logs,
|
||||
id: flatLogicFunction.id,
|
||||
name: flatLogicFunction.name,
|
||||
universalIdentifier: flatLogicFunction.universalIdentifier,
|
||||
applicationId: flatLogicFunction.applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.auditService
|
||||
.createContext({
|
||||
workspaceId,
|
||||
})
|
||||
.insertWorkspaceEvent(LOGIC_FUNCTION_EXECUTED_EVENT, {
|
||||
duration: resultLogicFunction.duration,
|
||||
status: resultLogicFunction.status,
|
||||
...(resultLogicFunction.error && {
|
||||
errorType: resultLogicFunction.error.errorType,
|
||||
}),
|
||||
functionId: flatLogicFunction.id,
|
||||
functionName: flatLogicFunction.name,
|
||||
});
|
||||
|
||||
return resultLogicFunction;
|
||||
}
|
||||
|
||||
private async throttleExecution(workspaceId: string) {
|
||||
try {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
`${workspaceId}-logic-function-execution`,
|
||||
1,
|
||||
this.twentyConfigService.get('LOGIC_FUNCTION_EXEC_THROTTLE_LIMIT'),
|
||||
this.twentyConfigService.get('LOGIC_FUNCTION_EXEC_THROTTLE_TTL'),
|
||||
);
|
||||
} catch {
|
||||
throw new LogicFunctionExecutionException(
|
||||
'Logic function execution rate limit exceeded',
|
||||
LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async callWithTimeout<T>({
|
||||
callback,
|
||||
timeoutMs,
|
||||
}: {
|
||||
callback: () => Promise<T>;
|
||||
timeoutMs: number;
|
||||
}): Promise<T> {
|
||||
return Promise.race([
|
||||
callback(),
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Execution timed out')), timeoutMs),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LogicFunctionLayerService } from 'src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service';
|
||||
import { LogicFunctionLayerEntity } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LogicFunctionLayerEntity]),
|
||||
WorkspaceCacheModule,
|
||||
],
|
||||
providers: [LogicFunctionLayerService],
|
||||
exports: [LogicFunctionLayerService],
|
||||
})
|
||||
export class CoreLogicFunctionLayerModule {}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { LogicFunctionLayerEntity } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity';
|
||||
import { CreateLogicFunctionLayerInput } from 'src/engine/metadata-modules/logic-function-layer/dtos/create-logic-function-layer.input';
|
||||
import { getLastCommonLayerDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies';
|
||||
import { logicFunctionCreateHash } from 'src/engine/metadata-modules/logic-function/utils/logic-function-create-hash.utils';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class LogicFunctionLayerService {
|
||||
constructor(
|
||||
@InjectRepository(LogicFunctionLayerEntity)
|
||||
private readonly logicFunctionLayerRepository: Repository<LogicFunctionLayerEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
{ packageJson, yarnLock }: CreateLogicFunctionLayerInput,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const checksum = logicFunctionCreateHash(yarnLock);
|
||||
|
||||
const logicFunctionLayer = this.logicFunctionLayerRepository.create({
|
||||
packageJson,
|
||||
yarnLock,
|
||||
checksum,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const savedLayer =
|
||||
await this.logicFunctionLayerRepository.save(logicFunctionLayer);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'logicFunctionLayerMaps',
|
||||
]);
|
||||
|
||||
return savedLayer;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: QueryDeepPartialEntity<LogicFunctionLayerEntity>,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const checksum = data.yarnLock
|
||||
? logicFunctionCreateHash(data.yarnLock as string)
|
||||
: undefined;
|
||||
|
||||
const updateData = { ...data, ...(checksum && { checksum }) };
|
||||
|
||||
const result = await this.logicFunctionLayerRepository.update(
|
||||
id,
|
||||
updateData,
|
||||
);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'logicFunctionLayerMaps',
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async createCommonLayerIfNotExist(workspaceId: string) {
|
||||
const { packageJson, yarnLock } = await getLastCommonLayerDependencies();
|
||||
const checksum = logicFunctionCreateHash(yarnLock);
|
||||
const commonLayer = await this.logicFunctionLayerRepository.findOne({
|
||||
where: {
|
||||
checksum,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(commonLayer)) {
|
||||
return commonLayer;
|
||||
}
|
||||
|
||||
return this.create({ packageJson, yarnLock }, workspaceId);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Scope } from '@nestjs/common';
|
||||
|
||||
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 { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service';
|
||||
|
||||
export type LogicFunctionTriggerJobData = {
|
||||
logicFunctionId: string;
|
||||
workspaceId: string;
|
||||
payload?: object;
|
||||
};
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.logicFunctionQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class LogicFunctionTriggerJob {
|
||||
constructor(
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
) {}
|
||||
|
||||
@Process(LogicFunctionTriggerJob.name)
|
||||
async handle(logicFunctionPayloads: LogicFunctionTriggerJobData[]) {
|
||||
await Promise.all(
|
||||
logicFunctionPayloads.map(
|
||||
async (logicFunctionPayload) =>
|
||||
await this.logicFunctionExecutorService.executeOneLogicFunction({
|
||||
id: logicFunctionPayload.logicFunctionId,
|
||||
workspaceId: logicFunctionPayload.workspaceId,
|
||||
payload: logicFunctionPayload.payload ?? {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
|
||||
import { CronTriggerCronJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job';
|
||||
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
|
||||
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LogicFunctionEntity, WorkspaceEntity]),
|
||||
TokenModule,
|
||||
WorkspaceDomainsModule,
|
||||
],
|
||||
providers: [
|
||||
LogicFunctionTriggerJob,
|
||||
CronTriggerCronJob,
|
||||
CronTriggerCronCommand,
|
||||
CallDatabaseEventTriggerJobsJob,
|
||||
RouteTriggerService,
|
||||
],
|
||||
exports: [CronTriggerCronCommand, RouteTriggerService],
|
||||
})
|
||||
export class LogicFunctionTriggerModule {}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
CRON_TRIGGER_CRON_PATTERN,
|
||||
CronTriggerCronJob,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job';
|
||||
|
||||
@Command({
|
||||
name: 'cron:trigger:start-cron-trigger',
|
||||
description: 'Starts a cron job to trigger cron triggered logic functions',
|
||||
})
|
||||
export class CronTriggerCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CronTriggerCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: CRON_TRIGGER_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
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 { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
LogicFunctionTriggerJob,
|
||||
LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { shouldRunNow } from 'src/utils/should-run-now.utils';
|
||||
|
||||
export const CRON_TRIGGER_CRON_PATTERN = '* * * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class CronTriggerCronJob {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
) {}
|
||||
|
||||
@Process(CronTriggerCronJob.name)
|
||||
@SentryCronMonitor(CronTriggerCronJob.name, CRON_TRIGGER_CRON_PATTERN)
|
||||
async handle() {
|
||||
const activeWorkspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const activeWorkspace of activeWorkspaces) {
|
||||
const logicFunctionsWithCronTrigger =
|
||||
await this.logicFunctionRepository.find({
|
||||
where: {
|
||||
workspaceId: activeWorkspace.id,
|
||||
cronTriggerSettings: Not(IsNull()),
|
||||
},
|
||||
select: ['id', 'cronTriggerSettings', 'workspaceId'],
|
||||
});
|
||||
|
||||
for (const logicFunction of logicFunctionsWithCronTrigger) {
|
||||
const cronSettings = logicFunction.cronTriggerSettings;
|
||||
|
||||
if (!isDefined(cronSettings?.pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldRunNow(cronSettings.pattern, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<LogicFunctionTriggerJobData[]>(
|
||||
LogicFunctionTriggerJob.name,
|
||||
[
|
||||
{
|
||||
logicFunctionId: logicFunction.id,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import chunk from 'lodash.chunk';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
|
||||
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
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 { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { transformEventBatchToEventPayloads } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads';
|
||||
import {
|
||||
LogicFunctionTriggerJob,
|
||||
LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
|
||||
const DATABASE_EVENT_JOBS_CHUNK_SIZE = 20;
|
||||
|
||||
@Processor(MessageQueue.triggerQueue)
|
||||
export class CallDatabaseEventTriggerJobsJob {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
) {}
|
||||
|
||||
@Process(CallDatabaseEventTriggerJobsJob.name)
|
||||
async handle(workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>) {
|
||||
const logicFunctionsWithDatabaseEventTrigger =
|
||||
await this.logicFunctionRepository.find({
|
||||
where: {
|
||||
workspaceId: workspaceEventBatch.workspaceId,
|
||||
databaseEventTriggerSettings: Not(IsNull()),
|
||||
},
|
||||
select: ['id', 'databaseEventTriggerSettings', 'workspaceId'],
|
||||
});
|
||||
|
||||
const logicFunctionsToTrigger =
|
||||
logicFunctionsWithDatabaseEventTrigger.filter((logicFunction) =>
|
||||
this.shouldTriggerJob({
|
||||
workspaceEventBatch,
|
||||
eventName: isDefined(logicFunction.databaseEventTriggerSettings)
|
||||
? logicFunction.databaseEventTriggerSettings.eventName
|
||||
: '',
|
||||
}),
|
||||
);
|
||||
|
||||
const logicFunctionPayloads = transformEventBatchToEventPayloads({
|
||||
logicFunctions: logicFunctionsToTrigger,
|
||||
workspaceEventBatch,
|
||||
});
|
||||
|
||||
if (logicFunctionPayloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logicFunctionPayloadsChunks = chunk(
|
||||
logicFunctionPayloads,
|
||||
DATABASE_EVENT_JOBS_CHUNK_SIZE,
|
||||
);
|
||||
|
||||
for (const logicFunctionPayloadsChunk of logicFunctionPayloadsChunks) {
|
||||
await this.messageQueueService.add<LogicFunctionTriggerJobData[]>(
|
||||
LogicFunctionTriggerJob.name,
|
||||
logicFunctionPayloadsChunk,
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldTriggerJob({
|
||||
workspaceEventBatch,
|
||||
eventName,
|
||||
}: {
|
||||
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
|
||||
eventName: string;
|
||||
}) {
|
||||
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
|
||||
|
||||
const validEventNames = [
|
||||
`${nameSingular}.${operation}`,
|
||||
`*.${operation}`,
|
||||
`${nameSingular}.*`,
|
||||
'*.*',
|
||||
];
|
||||
|
||||
return validEventNames.includes(eventName);
|
||||
}
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
|
||||
|
||||
import { transformEventBatchToEventPayloads } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import type { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
|
||||
const createMockLogicFunction = (
|
||||
overrides: Partial<LogicFunctionEntity> = {},
|
||||
): LogicFunctionEntity =>
|
||||
({
|
||||
id: 'function-1',
|
||||
workspaceId: 'workspace-1',
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
},
|
||||
...overrides,
|
||||
}) as LogicFunctionEntity;
|
||||
|
||||
const createMockEvent = (
|
||||
overrides: Partial<ObjectRecordEvent> = {},
|
||||
): ObjectRecordEvent =>
|
||||
({
|
||||
recordId: 'record-1',
|
||||
properties: {
|
||||
after: {},
|
||||
},
|
||||
...overrides,
|
||||
}) as ObjectRecordEvent;
|
||||
|
||||
const createMockWorkspaceEventBatch = (
|
||||
overrides: Partial<WorkspaceEventBatch<ObjectRecordEvent>> = {},
|
||||
): WorkspaceEventBatch<ObjectRecordEvent> => ({
|
||||
name: 'company.updated',
|
||||
workspaceId: 'workspace-1',
|
||||
objectMetadata: getFlatObjectMetadataMock({
|
||||
universalIdentifier: 'company-uuid',
|
||||
nameSingular: 'company',
|
||||
}),
|
||||
events: [createMockEvent()],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('transformEventBatchToEventPayloads', () => {
|
||||
describe('basic transformation', () => {
|
||||
it('should transform a single event batch with a single logic function', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch();
|
||||
const logicFunctions = [createMockLogicFunction()];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
logicFunctionId: 'function-1',
|
||||
workspaceId: 'workspace-1',
|
||||
payload: expect.objectContaining({
|
||||
name: 'company.updated',
|
||||
workspaceId: 'workspace-1',
|
||||
recordId: 'record-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create multiple payloads for multiple events in a batch', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
events: [
|
||||
createMockEvent({ recordId: 'record-1' }),
|
||||
createMockEvent({ recordId: 'record-2' }),
|
||||
createMockEvent({ recordId: 'record-3' }),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [createMockLogicFunction()];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(
|
||||
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
|
||||
).toEqual(['record-1', 'record-2', 'record-3']);
|
||||
});
|
||||
|
||||
it('should create payloads for each logic function', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch();
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
id: 'function-1',
|
||||
}),
|
||||
createMockLogicFunction({
|
||||
id: 'function-2',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.logicFunctionId)).toEqual([
|
||||
'function-1',
|
||||
'function-2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatedFields filtering', () => {
|
||||
it('should include all events when updatedFields is undefined', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
databaseEventTriggerSettings: { eventName: 'company.updated' },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include all events when updatedFields is empty array', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter events to only those matching updatedFields', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-3',
|
||||
properties: { after: {}, updatedFields: ['name', 'description'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: ['name'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(
|
||||
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
|
||||
).toEqual(['record-1', 'record-3']);
|
||||
});
|
||||
|
||||
it('should filter events matching any of the specified updatedFields', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-3',
|
||||
properties: { after: {}, updatedFields: ['phone'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: ['name', 'address'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(
|
||||
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
|
||||
).toEqual(['record-1', 'record-2']);
|
||||
});
|
||||
|
||||
it('should return no events when none match the updatedFields filter', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: ['phone'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle different updatedFields filters per logic function', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
name: 'company.updated',
|
||||
events: [
|
||||
createMockEvent({
|
||||
recordId: 'record-1',
|
||||
properties: { after: {}, updatedFields: ['name'] },
|
||||
}),
|
||||
createMockEvent({
|
||||
recordId: 'record-2',
|
||||
properties: { after: {}, updatedFields: ['address'] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const logicFunctions = [
|
||||
createMockLogicFunction({
|
||||
id: 'function-1',
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: ['name'],
|
||||
},
|
||||
}),
|
||||
createMockLogicFunction({
|
||||
id: 'function-2',
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'company.updated',
|
||||
updatedFields: ['address'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const function1Payloads = result.filter(
|
||||
(r) => r.logicFunctionId === 'function-1',
|
||||
);
|
||||
const function2Payloads = result.filter(
|
||||
(r) => r.logicFunctionId === 'function-2',
|
||||
);
|
||||
|
||||
expect(function1Payloads).toHaveLength(1);
|
||||
expect((function1Payloads[0].payload as ObjectRecordEvent).recordId).toBe(
|
||||
'record-1',
|
||||
);
|
||||
|
||||
expect(function2Payloads).toHaveLength(1);
|
||||
expect((function2Payloads[0].payload as ObjectRecordEvent).recordId).toBe(
|
||||
'record-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array when no logic functions provided', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch();
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions: [],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when no events in batch', () => {
|
||||
const workspaceEventBatch = createMockWorkspaceEventBatch({
|
||||
events: [],
|
||||
});
|
||||
const logicFunctions = [createMockLogicFunction()];
|
||||
|
||||
const result = transformEventBatchToEventPayloads({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordEvent,
|
||||
} from 'twenty-shared/database-events';
|
||||
|
||||
import { type LogicFunctionTriggerJobData } from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import type { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
|
||||
export const transformEventBatchToEventPayloads = ({
|
||||
workspaceEventBatch,
|
||||
logicFunctions,
|
||||
}: {
|
||||
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
|
||||
logicFunctions: LogicFunctionEntity[];
|
||||
}): LogicFunctionTriggerJobData[] => {
|
||||
const result: LogicFunctionTriggerJobData[] = [];
|
||||
const { events, ...batchEventInfo } = workspaceEventBatch;
|
||||
const [, operation] = workspaceEventBatch.name.split('.');
|
||||
|
||||
for (const logicFunction of logicFunctions) {
|
||||
const triggerUpdatedFields =
|
||||
logicFunction.databaseEventTriggerSettings?.updatedFields;
|
||||
|
||||
const filteredEvents = filterEventsByUpdatedFields({
|
||||
events,
|
||||
operation,
|
||||
triggerUpdatedFields,
|
||||
});
|
||||
|
||||
for (const event of filteredEvents) {
|
||||
const payload: DatabaseEventPayload = { ...batchEventInfo, ...event };
|
||||
|
||||
result.push({
|
||||
logicFunctionId: logicFunction.id,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const filterEventsByUpdatedFields = ({
|
||||
events,
|
||||
operation,
|
||||
triggerUpdatedFields,
|
||||
}: {
|
||||
events: ObjectRecordEvent[];
|
||||
operation: string;
|
||||
triggerUpdatedFields?: string[];
|
||||
}): ObjectRecordEvent[] => {
|
||||
if (
|
||||
operation !== 'updated' ||
|
||||
!isDefined(triggerUpdatedFields) ||
|
||||
triggerUpdatedFields.length === 0
|
||||
) {
|
||||
return events;
|
||||
}
|
||||
|
||||
return events.filter((event) => {
|
||||
const eventUpdatedFields = (
|
||||
event.properties as { updatedFields?: string[] }
|
||||
)?.updatedFields;
|
||||
|
||||
if (!isDefined(eventUpdatedFields) || eventUpdatedFields.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return eventUpdatedFields.some((fieldName: string) =>
|
||||
triggerUpdatedFields.includes(fieldName),
|
||||
);
|
||||
});
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
RouteTriggerException,
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
|
||||
import type { CustomException } from 'src/utils/custom-exception';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
|
||||
@Catch(RouteTriggerException)
|
||||
export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: RouteTriggerException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
switch (exception.code) {
|
||||
case RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.ROUTE_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.TRIGGER_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
403,
|
||||
);
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
|
||||
case RouteTriggerExceptionCode.ROUTE_PATH_ALREADY_EXIST:
|
||||
default: {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum RouteTriggerExceptionCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
ROUTE_NOT_FOUND = 'ROUTE_NOT_FOUND',
|
||||
TRIGGER_NOT_FOUND = 'TRIGGER_NOT_FOUND',
|
||||
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
|
||||
ROUTE_ALREADY_EXIST = 'ROUTE_ALREADY_EXIST',
|
||||
ROUTE_PATH_ALREADY_EXIST = 'ROUTE_PATH_ALREADY_EXIST',
|
||||
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
|
||||
LOGIC_FUNCTION_EXECUTION_ERROR = 'LOGIC_FUNCTION_EXECUTION_ERROR',
|
||||
}
|
||||
|
||||
const getRouteTriggerExceptionUserFriendlyMessage = (
|
||||
code: RouteTriggerExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND:
|
||||
return msg`Workspace not found.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_NOT_FOUND:
|
||||
return msg`Route not found.`;
|
||||
case RouteTriggerExceptionCode.TRIGGER_NOT_FOUND:
|
||||
return msg`Trigger not found.`;
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`Logic function not found.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
|
||||
return msg`Route already exists.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_PATH_ALREADY_EXIST:
|
||||
return msg`Route path already exists.`;
|
||||
case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION:
|
||||
return msg`You do not have permission to perform this action.`;
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
|
||||
return msg`Logic function execution failed.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class RouteTriggerException extends CustomException<RouteTriggerExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: RouteTriggerExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getRouteTriggerExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { match } from 'path-to-regexp';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { HTTPMethod } from 'twenty-shared/types';
|
||||
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service';
|
||||
import {
|
||||
RouteTriggerException,
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
|
||||
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RouteTriggerService {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
) {}
|
||||
|
||||
private async getLogicFunctionWithPathParamsOrFail({
|
||||
request,
|
||||
httpMethod,
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}): Promise<{
|
||||
logicFunction: LogicFunctionEntity;
|
||||
pathParams: Partial<Record<string, string | string[]>>;
|
||||
}> {
|
||||
const host = `${request.protocol}://${request.get('host')}`;
|
||||
|
||||
const workspace =
|
||||
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
host,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new RouteTriggerException(
|
||||
'Workspace not found',
|
||||
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const logicFunctionsWithHttpRouteTrigger =
|
||||
await this.logicFunctionRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
httpRouteTriggerSettings: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
const requestPath = request.path.replace(/^\/s\//, '/');
|
||||
|
||||
for (const logicFunction of logicFunctionsWithHttpRouteTrigger) {
|
||||
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
if (
|
||||
!isDefined(httpRouteSettings) ||
|
||||
httpRouteSettings.httpMethod !== httpMethod
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeMatcher = match(httpRouteSettings.path, {
|
||||
decode: decodeURIComponent,
|
||||
});
|
||||
const routeMatched = routeMatcher(requestPath);
|
||||
|
||||
if (routeMatched) {
|
||||
return {
|
||||
logicFunction,
|
||||
pathParams: routeMatched.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new RouteTriggerException(
|
||||
'No Route trigger found',
|
||||
RouteTriggerExceptionCode.TRIGGER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
private async validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId,
|
||||
}: {
|
||||
request: Request;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const authContext =
|
||||
await this.accessTokenService.validateTokenByRequest(request);
|
||||
|
||||
if (!isDefined(authContext.workspace)) {
|
||||
throw new RouteTriggerException(
|
||||
'Workspace not found',
|
||||
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (authContext.workspace.id !== workspaceId) {
|
||||
throw new RouteTriggerException(
|
||||
'You are not authorized',
|
||||
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return authContext;
|
||||
}
|
||||
|
||||
async handle({
|
||||
request,
|
||||
httpMethod,
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}) {
|
||||
const { logicFunction, pathParams } =
|
||||
await this.getLogicFunctionWithPathParamsOrFail({
|
||||
request,
|
||||
httpMethod,
|
||||
});
|
||||
|
||||
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
if (httpRouteSettings?.isAuthRequired) {
|
||||
await this.validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
const event = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: pathParams,
|
||||
forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
|
||||
});
|
||||
|
||||
const result =
|
||||
await this.logicFunctionExecutorService.executeOneLogicFunction({
|
||||
id: logicFunction.id,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
payload: event,
|
||||
});
|
||||
|
||||
if (!isDefined(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new RouteTriggerException(
|
||||
result.error.errorMessage,
|
||||
RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { type Request } from 'express';
|
||||
|
||||
import {
|
||||
buildLogicFunctionEvent,
|
||||
extractBody,
|
||||
filterRequestHeaders,
|
||||
normalizePathParameters,
|
||||
normalizeQueryStringParameters,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
|
||||
|
||||
describe('filterRequestHeaders', () => {
|
||||
it('should filter headers based on allowed names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
'x-custom-header': 'custom-value',
|
||||
'user-agent': 'test-agent',
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'authorization'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case-insensitive header names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
};
|
||||
const forwardedRequestHeaders = ['Content-Type', 'AUTHORIZATION'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no headers match', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when forwardedRequestHeaders is empty', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert array header values to comma-separated string', () => {
|
||||
const requestHeaders = {
|
||||
'x-custom-array-header': ['value1', 'value2', 'value3'],
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-array-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'x-custom-array-header': 'value1, value2, value3',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip undefined header values', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
'x-missing': undefined,
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'x-missing'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
it('should return null for undefined body', () => {
|
||||
const request = { body: undefined } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null body', () => {
|
||||
const request = { body: null } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse string body as JSON', () => {
|
||||
const request = { body: '{"key":"value"}' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON string body in raw property', () => {
|
||||
const request = { body: 'plain text body' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'plain text body' });
|
||||
});
|
||||
|
||||
it('should return object body as-is (parsed JSON)', () => {
|
||||
const request = {
|
||||
body: { key: 'value', nested: { foo: 'bar' } },
|
||||
} as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value', nested: { foo: 'bar' } });
|
||||
});
|
||||
|
||||
it('should parse Buffer body as JSON', () => {
|
||||
const request = {
|
||||
body: Buffer.from('{"buffered":"json"}'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ buffered: 'json' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON Buffer body in raw property', () => {
|
||||
const request = {
|
||||
body: Buffer.from('buffer content'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'buffer content' });
|
||||
});
|
||||
|
||||
it('should handle empty object body', () => {
|
||||
const request = { body: {} } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle array body', () => {
|
||||
const request = { body: [1, 2, 3] } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeQueryStringParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const query = { page: '1', limit: '10' };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1', limit: '10' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const query = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const query = { page: '1', missing: undefined };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should handle empty query object', () => {
|
||||
const query = {};
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should stringify nested objects', () => {
|
||||
const query = { filter: { name: 'test' } as unknown as string };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ filter: '{"name":"test"}' });
|
||||
});
|
||||
|
||||
it('should filter non-string values from arrays and join with commas', () => {
|
||||
const query = { ids: ['1', undefined as unknown as string, '2'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const pathParams = { id: '123', slug: 'test' };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123', slug: 'test' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const pathParams = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const pathParams = { id: '123', missing: undefined };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123' });
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const pathParams = {};
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLogicFunctionEvent', () => {
|
||||
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
headers: {},
|
||||
query: {},
|
||||
body: undefined,
|
||||
method: 'GET',
|
||||
path: '/test',
|
||||
...overrides,
|
||||
}) as Request;
|
||||
|
||||
it('should build a complete event from Express request', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
'user-agent': 'test',
|
||||
},
|
||||
query: { page: '1' },
|
||||
body: { data: 'test' },
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: { id: '123' },
|
||||
forwardedRequestHeaders: ['content-type', 'authorization'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
},
|
||||
queryStringParameters: { page: '1' },
|
||||
pathParameters: { id: '123' },
|
||||
body: { data: 'test' },
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve the request path as-is', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/api/users',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/s/api/users');
|
||||
});
|
||||
|
||||
it('should preserve path without prefix', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/api/users',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/api/users');
|
||||
});
|
||||
|
||||
it('should handle GET request with no body', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'GET',
|
||||
query: { search: 'test' },
|
||||
body: undefined,
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.body).toBeNull();
|
||||
expect(result.queryStringParameters).toEqual({ search: 'test' });
|
||||
});
|
||||
|
||||
it('should handle DELETE request with path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'DELETE',
|
||||
path: '/s/users/456',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: { userId: '456' },
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.method).toBe('DELETE');
|
||||
expect(result.pathParameters).toEqual({ userId: '456' });
|
||||
});
|
||||
|
||||
it('should filter only allowed headers', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key123',
|
||||
cookie: 'session=abc',
|
||||
},
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: ['x-api-key'],
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'x-api-key': 'key123',
|
||||
});
|
||||
expect(result.headers['authorization']).toBeUndefined();
|
||||
expect(result.headers['cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set isBase64Encoded to false', () => {
|
||||
const request = createMockRequest();
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.isBase64Encoded).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle complex path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/organizations/org1/users/user1/posts',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.pathParameters).toEqual({
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
});
|
||||
});
|
||||
});
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { type Request } from 'express';
|
||||
import { type LogicFunctionEvent } from 'twenty-shared/types';
|
||||
|
||||
/**
|
||||
* Filters HTTP headers from Express request based on allowed header names
|
||||
* Header names are case-insensitive as per HTTP specification
|
||||
*/
|
||||
export const filterRequestHeaders = ({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
requestHeaders: Request['headers'];
|
||||
forwardedRequestHeaders: string[];
|
||||
}): Record<string, string | undefined> => {
|
||||
const lowercaseForwardedHeaders = forwardedRequestHeaders.map((h) =>
|
||||
h.toLowerCase(),
|
||||
);
|
||||
|
||||
const filteredHeaders: Record<string, string | undefined> = {};
|
||||
|
||||
for (const headerName of lowercaseForwardedHeaders) {
|
||||
const headerValue = requestHeaders[headerName];
|
||||
|
||||
if (headerValue !== undefined) {
|
||||
filteredHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue.join(', ')
|
||||
: headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredHeaders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the body from Express request as an object
|
||||
* Express body-parser middleware parses JSON bodies automatically
|
||||
* Returns null if body is empty/undefined
|
||||
*/
|
||||
export const extractBody = (request: Request): object | null => {
|
||||
if (request.body === undefined || request.body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
|
||||
return request.body;
|
||||
}
|
||||
|
||||
if (typeof request.body === 'string') {
|
||||
try {
|
||||
return JSON.parse(request.body);
|
||||
} catch {
|
||||
return { raw: request.body };
|
||||
}
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(request.body)) {
|
||||
try {
|
||||
return JSON.parse(request.body.toString('utf-8'));
|
||||
} catch {
|
||||
return { raw: request.body.toString('utf-8') };
|
||||
}
|
||||
}
|
||||
|
||||
return { raw: String(request.body) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Express query parameters to a normalized string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizeQueryStringParameters = (
|
||||
query: Request['query'],
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const stringValues = value.filter(
|
||||
(v): v is string => typeof v === 'string',
|
||||
);
|
||||
|
||||
normalized[key] = stringValues.join(',');
|
||||
} else if (typeof value === 'string') {
|
||||
normalized[key] = value;
|
||||
} else if (typeof value === 'object') {
|
||||
normalized[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes path parameters to string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizePathParameters = (
|
||||
pathParams: Record<string, string | string[] | undefined>,
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
normalized[key] = value.join(',');
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an AWS HTTP API v2 compatible event from an Express request
|
||||
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
|
||||
*/
|
||||
export const buildLogicFunctionEvent = ({
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
}): LogicFunctionEvent => {
|
||||
return {
|
||||
headers: filterRequestHeaders({
|
||||
requestHeaders: request.headers,
|
||||
forwardedRequestHeaders,
|
||||
}),
|
||||
queryStringParameters: normalizeQueryStringParameters(request.query),
|
||||
pathParameters: normalizePathParameters(pathParameters),
|
||||
body: extractBody(request),
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type DynamicModule, Global, Module } from '@nestjs/common';
|
||||
|
||||
import { type LogicFunctionExecutorModuleAsyncOptions } from 'src/engine/core-modules/logic-function/logic-function-executor/interfaces/logic-function-executor.interface';
|
||||
|
||||
import { LogicFunctionBuildModule } from 'src/engine/core-modules/logic-function/logic-function-build/logic-function-build.module';
|
||||
import { LogicFunctionDriversModule } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-drivers.module';
|
||||
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module';
|
||||
import { CoreLogicFunctionLayerModule } from 'src/engine/core-modules/logic-function/logic-function-layer/logic-function-layer.module';
|
||||
import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module';
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
export class LogicFunctionModule {
|
||||
static forRootAsync(
|
||||
options: LogicFunctionExecutorModuleAsyncOptions,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: LogicFunctionModule,
|
||||
imports: [
|
||||
LogicFunctionDriversModule.forRootAsync(options),
|
||||
LogicFunctionExecutorModule,
|
||||
LogicFunctionBuildModule,
|
||||
CoreLogicFunctionLayerModule,
|
||||
LogicFunctionTriggerModule,
|
||||
],
|
||||
exports: [
|
||||
LogicFunctionDriversModule,
|
||||
LogicFunctionExecutorModule,
|
||||
LogicFunctionBuildModule,
|
||||
CoreLogicFunctionLayerModule,
|
||||
LogicFunctionTriggerModule,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,7 @@ import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspa
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module';
|
||||
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
@@ -68,8 +66,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
SubscriptionsModule,
|
||||
AuditJobModule,
|
||||
AiAgentMonitorModule,
|
||||
CronTriggerModule,
|
||||
DatabaseEventTriggerModule,
|
||||
LogicFunctionModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -15,6 +15,7 @@ import { type LoggerOptions } from 'typeorm/logger/LoggerOptions';
|
||||
import { type AwsRegion } from 'src/engine/core-modules/twenty-config/interfaces/aws-region.interface';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
import { LogicFunctionExecutorDriverType } from 'src/engine/core-modules/logic-function/logic-function-executor/interfaces/logic-function-executor.interface';
|
||||
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
@@ -22,7 +23,6 @@ import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.en
|
||||
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
|
||||
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
|
||||
import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces';
|
||||
import { LogicFunctionExecutorDriverType } from 'src/engine/core-modules/logic-function-executor/logic-function-executor.interface';
|
||||
import { type MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
|
||||
import { CastToLogLevelArray } from 'src/engine/core-modules/twenty-config/decorators/cast-to-log-level-array.decorator';
|
||||
import { CastToMeterDriverArray } from 'src/engine/core-modules/twenty-config/decorators/cast-to-meter-driver.decorator';
|
||||
|
||||
Reference in New Issue
Block a user