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:
Charles Bochet
2026-01-30 19:28:20 +01:00
committed by GitHub
parent cb5e5e4622
commit 4a770eafa1
73 changed files with 497 additions and 409 deletions
@@ -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({
@@ -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);
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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,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,
@@ -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';
@@ -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;
@@ -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,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 = {
@@ -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,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, {
@@ -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);
@@ -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;
@@ -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);
@@ -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);
@@ -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';
@@ -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(
@@ -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> => {
@@ -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',
@@ -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 {}
@@ -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),
),
]);
}
}
@@ -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 {}
@@ -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);
}
}
@@ -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 ?? {},
}),
),
);
}
}
@@ -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 {}
@@ -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,
},
},
});
}
}
@@ -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 },
);
}
}
}
}
@@ -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);
}
}
@@ -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);
});
});
});
@@ -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),
);
});
};
@@ -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,
);
}
}
}
}
@@ -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),
});
}
}
@@ -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;
}
}
@@ -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',
});
});
});
@@ -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,
},
},
};
};
@@ -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';