8723 workflow add editor in serverless function code step (#8805)

- create a serverless function when creating a new workflow code step
- add code editor in workflow code step
- move workflowVersion steps management from frontend to backend
  - add a custom resolver for workflow-version management
  - fix optimistic rendering on frontend
- fix css
- delete serverless function when deleting workflow code step

TODO
- Don't update serverlessFunction if no code change
- Factorize what can be between crud trigger and crud step
- Publish serverless version when activating workflow
- delete serverless functions when deleting workflow or workflowVersion
- fix optimistic rendering for code updates
- Unify CRUD types

<img width="1279" alt="image"
src="https://github.com/user-attachments/assets/3d97ee9f-4b96-4abc-9d36-5c0280058be4">
This commit is contained in:
martmull
2024-12-03 09:41:13 +01:00
committed by GitHub
parent 9d7632cb4f
commit d0ff1ffd5f
75 changed files with 2192 additions and 1527 deletions

View File

@ -0,0 +1,13 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowVersionStepException extends CustomException {
constructor(message: string, code: WorkflowVersionStepExceptionCode) {
super(message, code);
}
}
export enum WorkflowVersionStepExceptionCode {
UNKNOWN = 'UNKNOWN',
NOT_FOUND = 'NOT_FOUND',
UNDEFINED = 'UNDEFINED',
FAILURE = 'FAILURE',
}

View File

@ -1,12 +1,32 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { WorkflowCommandModule } from 'src/modules/workflow/common/commands/workflow-command.module';
import { WorkflowQueryHookModule } from 'src/modules/workflow/common/query-hooks/workflow-query-hook.module';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service';
import { WorkflowBuilderModule } from 'src/modules/workflow/workflow-builder/workflow-builder.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-introspection.module';
@Module({
imports: [WorkflowQueryHookModule, WorkflowCommandModule],
providers: [WorkflowCommonWorkspaceService],
exports: [WorkflowCommonWorkspaceService],
imports: [
WorkflowQueryHookModule,
WorkflowCommandModule,
WorkflowBuilderModule,
ServerlessFunctionModule,
CodeIntrospectionModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
],
providers: [
WorkflowCommonWorkspaceService,
WorkflowVersionStepWorkspaceService,
],
exports: [
WorkflowCommonWorkspaceService,
WorkflowVersionStepWorkspaceService,
],
})
export class WorkflowCommonModule {}

View File

@ -0,0 +1,363 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkflowBuilderWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-builder.workspace-service';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
const TRIGGER_STEP_ID = 'trigger';
const BASE_STEP_DEFINITION: BaseWorkflowActionSettings = {
input: {},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
};
@Injectable()
export class WorkflowVersionStepWorkspaceService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workflowBuilderWorkspaceService: WorkflowBuilderWorkspaceService,
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly codeIntrospectionService: CodeIntrospectionService,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
) {}
private async getStepDefaultDefinition({
type,
workspaceId,
}: {
type: WorkflowActionType;
workspaceId: string;
}): Promise<WorkflowAction> {
const newStepId = v4();
switch (`${type}`) {
case WorkflowActionType.CODE: {
const newServerlessFunction =
await this.serverlessFunctionService.createOneServerlessFunction(
{
name: 'A Serverless Function Code Workflow Step',
description: '',
},
workspaceId,
);
if (!isDefined(newServerlessFunction)) {
throw new WorkflowVersionStepException(
'Fail to create Code Step',
WorkflowVersionStepExceptionCode.FAILURE,
);
}
const sourceCode = (
await this.serverlessFunctionService.getServerlessFunctionSourceCode(
workspaceId,
newServerlessFunction.id,
'draft',
)
)?.[join('src', INDEX_FILE_NAME)];
const inputSchema = isDefined(sourceCode)
? this.codeIntrospectionService.getFunctionInputSchema(sourceCode)
: {};
const serverlessFunctionInput =
this.codeIntrospectionService.generateInputData(inputSchema, true);
return {
id: newStepId,
name: 'Code - Serverless Function',
type: WorkflowActionType.CODE,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft',
serverlessFunctionInput,
},
},
};
}
case WorkflowActionType.SEND_EMAIL: {
return {
id: newStepId,
name: 'Send Email',
type: WorkflowActionType.SEND_EMAIL,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
},
};
}
case `${WorkflowActionType.RECORD_CRUD}.${WorkflowRecordCRUDType.CREATE}`: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
where: { workspaceId, isActive: true, isSystem: false },
});
return {
id: newStepId,
name: 'Create Record',
type: WorkflowActionType.RECORD_CRUD,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
type: WorkflowRecordCRUDType.CREATE,
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
},
},
};
}
case `${WorkflowActionType.RECORD_CRUD}.${WorkflowRecordCRUDType.UPDATE}`: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
where: { workspaceId, isActive: true, isSystem: false },
});
return {
id: newStepId,
name: 'Update Record',
type: WorkflowActionType.RECORD_CRUD,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
type: WorkflowRecordCRUDType.UPDATE,
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
objectRecordId: '',
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,
WorkflowVersionStepExceptionCode.UNKNOWN,
);
}
}
private async enrichOutputSchema({
step,
workspaceId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
const result = { ...step };
const outputSchema =
await this.workflowBuilderWorkspaceService.computeStepOutputSchema({
step,
workspaceId,
});
result.settings = {
...result.settings,
outputSchema: outputSchema || {},
};
return result;
}
async createWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepType,
}: {
workspaceId: string;
workflowVersionId: string;
stepType: WorkflowActionType;
}): Promise<WorkflowActionDTO> {
const newStep = await this.getStepDefaultDefinition({
type: stepType,
workspaceId,
});
const enrichedNewStep = await this.enrichOutputSchema({
step: newStep,
workspaceId,
});
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
'workflowVersion',
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
await workflowVersionRepository.update(workflowVersion.id, {
steps: [...(workflowVersion.steps || []), enrichedNewStep],
});
return enrichedNewStep;
}
async updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step,
}: {
workspaceId: string;
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowAction> {
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
'workflowVersion',
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't update step from undefined steps",
WorkflowVersionStepExceptionCode.UNDEFINED,
);
}
const enrichedNewStep = await this.enrichOutputSchema({
step,
workspaceId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {
return enrichedNewStep;
} else {
return existingStep;
}
});
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
});
return enrichedNewStep;
}
async deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
}: {
workspaceId: string;
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowActionDTO> {
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
'workflowVersion',
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't delete step from undefined steps",
WorkflowVersionStepExceptionCode.UNDEFINED,
);
}
const stepToDelete = workflowVersion.steps.filter(
(step) => step.id === stepId,
)?.[0];
if (!isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
"Can't delete not existing step",
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const workflowVersionUpdates =
stepId === TRIGGER_STEP_ID
? { trigger: null }
: { steps: workflowVersion.steps.filter((step) => step.id !== stepId) };
await workflowVersionRepository.update(
workflowVersion.id,
workflowVersionUpdates,
);
switch (stepToDelete.type) {
case WorkflowActionType.CODE:
await this.serverlessFunctionService.deleteOneServerlessFunction(
stepToDelete.settings.input.serverlessFunctionId,
workspaceId,
);
}
return stepToDelete;
}
}

View File

@ -79,6 +79,14 @@ export class WorkflowVersionValidationWorkspaceService {
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
if (payload.data.steps) {
throw new WorkflowQueryValidationException(
'Updating workflowVersion steps directly is forbidden. ' +
'Use createWorkflowVersionStep, updateWorkflowVersionStep or deleteWorkflowVersionStep endpoint instead.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
async validateWorkflowVersionForDeleteOne(payload: DeleteOneResolverArgs) {