Trigger workflow run manually (#6696)

Fix https://github.com/twentyhq/twenty/issues/6669

- create a commun function `startWorkflowRun` that both create the run
object and the job for executing the workflow
- use it in both the `workflowEventJob` and the `runWorkflowVersion`
endpoint

Bonus:
- use filtering for exceptions instead of a util. It avoids doing a try
catch in all endpoint
This commit is contained in:
Thomas Trompette
2024-08-21 17:41:26 +02:00
committed by GitHub
parent da5dfb7a5b
commit 663acd56e4
43 changed files with 452 additions and 316 deletions

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CreatedByPreQueryHook } from 'src/engine/core-modules/actor/query-hooks/created-by.pre-query-hook';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@Module({
imports: [TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata')],
providers: [CreatedByPreQueryHook],
exports: [CreatedByPreQueryHook],
})
export class ActorModule {}

View File

@ -0,0 +1,114 @@
import { Logger } from '@nestjs/common/services/logger.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { buildCreatedByFromWorkspaceMember } from 'src/engine/core-modules/actor/utils/build-created-by-from-workspace-member.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type CustomWorkspaceItem = Omit<
CustomWorkspaceEntity,
'createdAt' | 'updatedAt'
> & {
createdAt: string;
updatedAt: string;
};
@WorkspaceQueryHook(`*.createMany`)
export class CreatedByPreQueryHook implements WorkspaceQueryHookInstance {
private readonly logger = new Logger(CreatedByPreQueryHook.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
async execute(
authContext: AuthContext,
objectName: string,
payload: CreateManyResolverArgs<CustomWorkspaceItem>,
): Promise<CreateManyResolverArgs<CustomWorkspaceItem>> {
let createdBy: ActorMetadata | null = null;
// TODO: Once all objects have it, we can remove this check
const createdByFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
object: {
nameSingular: objectName,
},
name: 'createdBy',
workspaceId: authContext.workspace.id,
},
});
if (!createdByFieldMetadata) {
return payload;
}
// If user is logged in, we use the workspace member
if (authContext.workspaceMemberId && authContext.user) {
createdBy = buildCreatedByFromWorkspaceMember(
authContext.workspaceMemberId,
authContext.user,
);
// TODO: remove that code once we have the workspace member id in all tokens
} else if (authContext.user) {
this.logger.warn("User doesn't have a workspace member id in the token");
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
authContext.workspace.id,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: authContext.user?.id,
},
});
if (!workspaceMember) {
throw new Error(
`Workspace member can't be found for user ${authContext.user.id}`,
);
}
createdBy = {
source: FieldActorSource.MANUAL,
workspaceMemberId: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
};
}
if (authContext.apiKey) {
createdBy = {
source: FieldActorSource.API,
name: authContext.apiKey.name,
};
}
for (const datum of payload.data) {
// Front-end can fill the source field
if (createdBy && (!datum.createdBy || !datum.createdBy.name)) {
datum.createdBy = {
...createdBy,
source: datum.createdBy?.source ?? createdBy.source,
};
}
}
return payload;
}
}

View File

@ -0,0 +1,11 @@
import { User } from 'src/engine/core-modules/user/user.entity';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
export const buildCreatedByFromWorkspaceMember = (
workspaceMemberId: string,
user: User,
) => ({
workspaceMemberId,
source: FieldActorSource.MANUAL,
name: `${user.firstName} ${user.lastName}`,
});

View File

@ -16,6 +16,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { TokenService } from './token.service';
@ -66,6 +67,10 @@ describe('TokenService', () => {
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
},
{
provide: TwentyORMGlobalManager,
useValue: {},
},
],
}).compile();

View File

@ -41,6 +41,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class TokenService {
@ -55,6 +57,7 @@ export class TokenService {
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly emailService: EmailService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async generateAccessToken(
@ -91,9 +94,33 @@ export class TokenService {
);
}
const workspaceIdNonNullable = workspaceId
? workspaceId
: user.defaultWorkspace.id;
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceIdNonNullable,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
});
if (!workspaceMember) {
throw new AuthException(
'User is not a member of the workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const jwtPayload: JwtPayload = {
sub: user.id,
workspaceId: workspaceId ? workspaceId : user.defaultWorkspace.id,
workspaceMemberId: workspaceMember.id,
};
return {
@ -247,11 +274,10 @@ export class TokenService {
this.environmentService.get('ACCESS_TOKEN_SECRET'),
);
const { user, apiKey, workspace } = await this.jwtStrategy.validate(
decoded as JwtPayload,
);
const { user, apiKey, workspace, workspaceMemberId } =
await this.jwtStrategy.validate(decoded as JwtPayload);
return { user, apiKey, workspace };
return { user, apiKey, workspace, workspaceMemberId };
}
async verifyLoginToken(loginToken: string): Promise<string> {

View File

@ -17,7 +17,12 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
export type JwtPayload = { sub: string; workspaceId: string; jti?: string };
export type JwtPayload = {
sub: string;
workspaceId: string;
workspaceMemberId: string;
jti?: string;
};
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -95,6 +100,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
}
return { user, apiKey, workspace };
// We don't check if the user is a member of the workspace yet
const workspaceMemberId = payload.workspaceMemberId;
return { user, apiKey, workspace, workspaceMemberId };
}
}

View File

@ -5,5 +5,6 @@ import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-
export type AuthContext = {
user?: User | null | undefined;
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
workspaceMemberId?: string;
workspace: Workspace;
};

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AISQLQueryModule } from 'src/engine/core-modules/ai-sql-query/ai-sql-query.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
@ -11,7 +12,7 @@ import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timel
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
import { PostgresCredentialsModule } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkflowTriggerCoreModule } from 'src/engine/core-modules/workflow/core-workflow-trigger.module';
import { WorkflowTriggerApiModule } from 'src/engine/core-modules/workflow/workflow-trigger-api.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@ -36,8 +37,9 @@ import { FileModule } from './file/file.module';
WorkspaceModule,
AISQLQueryModule,
PostgresCredentialsModule,
WorkflowTriggerCoreModule,
WorkflowTriggerApiModule,
WorkspaceEventEmitterModule,
ActorModule,
],
exports: [
AnalyticsModule,

View File

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/workflow-trigger.resolver';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workflow-runner.module';
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.workspace-service';
@Module({
imports: [WorkflowCommonModule, WorkflowRunnerModule],
providers: [WorkflowTriggerWorkspaceService, WorkflowTriggerResolver],
})
export class WorkflowTriggerCoreModule {}

View File

@ -0,0 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('WorkflowRun')
export class WorkflowRunDTO {
@Field(() => UUIDScalarType)
workflowRunId: string;
}

View File

@ -1,14 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsObject } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
@ObjectType('WorkflowTriggerResult')
export class WorkflowTriggerResultDTO {
@IsObject()
@Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format',
nullable: true,
})
result?: JSON;
}

View File

@ -1,3 +1,5 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import {
InternalServerError,
UserInputError,
@ -7,18 +9,19 @@ import {
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/workflow-trigger.exception';
export const workflowTriggerGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkflowTriggerException) {
switch (error.code) {
@Catch(WorkflowTriggerException)
export class WorkflowTriggerGraphqlApiExceptionFilter
implements ExceptionFilter
{
catch(exception: WorkflowTriggerException) {
switch (exception.code) {
case WorkflowTriggerExceptionCode.INVALID_INPUT:
throw new UserInputError(error.message);
throw new UserInputError(exception.message);
case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER:
case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION:
case WorkflowTriggerExceptionCode.INVALID_ACTION_TYPE:
default:
throw new InternalServerError(error.message);
throw new InternalServerError(exception.message);
}
}
throw error;
};
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/workflow-trigger.resolver';
import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/workflow-trigger.module';
@Module({
imports: [WorkflowTriggerModule],
providers: [WorkflowTriggerResolver],
})
export class WorkflowTriggerApiModule {}

View File

@ -1,14 +1,18 @@
import { UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { User } from 'src/engine/core-modules/user/user.entity';
import { RunWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto';
import { WorkflowTriggerResultDTO } from 'src/engine/core-modules/workflow/dtos/workflow-trigger-result.dto';
import { workflowTriggerGraphqlApiExceptionHandler } from 'src/engine/core-modules/workflow/utils/workflow-trigger-graphql-api-exception-handler.util';
import { WorkflowRunDTO } from 'src/engine/core-modules/workflow/dtos/workflow-run.dto';
import { WorkflowTriggerGraphqlApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-graphql-api-exception.filter';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.workspace-service';
@UseGuards(JwtAuthGuard)
@Resolver()
@UseGuards(JwtAuthGuard)
@UseFilters(WorkflowTriggerGraphqlApiExceptionFilter)
export class WorkflowTriggerResolver {
constructor(
private readonly workflowTriggerWorkspaceService: WorkflowTriggerWorkspaceService,
@ -18,28 +22,22 @@ export class WorkflowTriggerResolver {
async enableWorkflowTrigger(
@Args('workflowVersionId') workflowVersionId: string,
) {
try {
return await this.workflowTriggerWorkspaceService.enableWorkflowTrigger(
workflowVersionId,
);
} catch (error) {
workflowTriggerGraphqlApiExceptionHandler(error);
}
return await this.workflowTriggerWorkspaceService.enableWorkflowTrigger(
workflowVersionId,
);
}
@Mutation(() => WorkflowTriggerResultDTO)
@Mutation(() => WorkflowRunDTO)
async runWorkflowVersion(
@AuthWorkspaceMemberId() workspaceMemberId: string,
@AuthUser() user: User,
@Args('input') { workflowVersionId, payload }: RunWorkflowVersionInput,
) {
try {
return {
result: await this.workflowTriggerWorkspaceService.runWorkflowVersion(
workflowVersionId,
payload ?? {},
),
};
} catch (error) {
workflowTriggerGraphqlApiExceptionHandler(error);
}
return await this.workflowTriggerWorkspaceService.runWorkflowVersion(
workflowVersionId,
payload ?? {},
workspaceMemberId,
user,
);
}
}