diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 4c0bde59d9..e2dd27bf71 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -8,7 +8,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { Role } from '@db/entities/Role'; import config from '@/config'; -import { TagRepository } from '@/databases/repositories'; +import { TagService } from '@/services/tag.service'; import Container from 'typedi'; function insertIf(condition: boolean, elements: string[]): string[] { @@ -64,7 +64,7 @@ export async function getWorkflowById(id: string): Promise { - const dbTags = await Container.get(TagRepository).find({ + const dbTags = await Container.get(TagService).findMany({ where: { name: In(tags) }, relations: ['workflows'], }); diff --git a/packages/cli/src/TagHelpers.ts b/packages/cli/src/TagHelpers.ts deleted file mode 100644 index bbc9741947..0000000000 --- a/packages/cli/src/TagHelpers.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { EntityManager } from 'typeorm'; -import type { TagEntity } from '@db/entities/TagEntity'; -import type { ITagToImport, IWorkflowToImport } from '@/Interfaces'; -import { TagRepository } from './databases/repositories'; -import Container from 'typedi'; - -// ---------------------------------- -// utils -// ---------------------------------- - -/** - * Sort tags based on the order of the tag IDs in the request. - */ -export function sortByRequestOrder( - tags: TagEntity[], - { requestOrder }: { requestOrder: string[] }, -) { - const tagMap = tags.reduce>((acc, tag) => { - acc[tag.id] = tag; - return acc; - }, {}); - - return requestOrder.map((tagId) => tagMap[tagId]); -} - -// ---------------------------------- -// mutations -// ---------------------------------- - -const createTag = async (transactionManager: EntityManager, name: string): Promise => { - const tag = Container.get(TagRepository).create({ name: name.trim() }); - return transactionManager.save(tag); -}; - -const findOrCreateTag = async ( - transactionManager: EntityManager, - importTag: ITagToImport, - tagsEntities: TagEntity[], -): Promise => { - // Assume tag is identical if createdAt date is the same to preserve a changed tag name - const identicalMatch = tagsEntities.find( - (existingTag) => - existingTag.id === importTag.id && - existingTag.createdAt && - importTag.createdAt && - existingTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), - ); - if (identicalMatch) { - return identicalMatch; - } - - const nameMatch = tagsEntities.find((existingTag) => existingTag.name === importTag.name); - if (nameMatch) { - return nameMatch; - } - - const created = await createTag(transactionManager, importTag.name); - tagsEntities.push(created); - return created; -}; - -const hasTags = (workflow: IWorkflowToImport) => - 'tags' in workflow && Array.isArray(workflow.tags) && workflow.tags.length > 0; - -/** - * Set tag IDs to use existing tags, creates a new tag if no matching tag could be found - */ -export async function setTagsForImport( - transactionManager: EntityManager, - workflow: IWorkflowToImport, - tags: TagEntity[], -): Promise { - if (!hasTags(workflow)) { - return; - } - - const workflowTags = workflow.tags; - const tagLookupPromises = []; - for (let i = 0; i < workflowTags.length; i++) { - if (workflowTags[i]?.name) { - const lookupPromise = findOrCreateTag(transactionManager, workflowTags[i], tags).then( - (tag) => { - workflowTags[i] = { - id: tag.id, - name: tag.name, - }; - }, - ); - tagLookupPromises.push(lookupPromise); - } - } - - await Promise.all(tagLookupPromises); -} diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 2cd8bd2b27..48146e9583 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -11,14 +11,13 @@ import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; -import { setTagsForImport } from '@/TagHelpers'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; import { BaseCommand, UM_FIX_INSTRUCTION } from '../BaseCommand'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; -import { TagRepository } from '@/databases/repositories'; +import { TagService } from '@/services/tag.service'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -66,6 +65,8 @@ export class ImportWorkflowsCommand extends BaseCommand { private transactionManager: EntityManager; + private tagService = Container.get(TagService); + async init() { disableAutoGeneratedIds(WorkflowEntity); await super.init(); @@ -93,7 +94,7 @@ export class ImportWorkflowsCommand extends BaseCommand { const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); const credentials = await Db.collections.Credentials.find(); - const tags = await Container.get(TagRepository).find(); + const tags = await this.tagService.getAll(); let totalImported = 0; @@ -133,7 +134,7 @@ export class ImportWorkflowsCommand extends BaseCommand { } if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await setTagsForImport(transactionManager, workflow, tags); + await this.tagService.setTagsForImport(transactionManager, workflow, tags); } if (workflow.active) { @@ -183,7 +184,7 @@ export class ImportWorkflowsCommand extends BaseCommand { } } if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await setTagsForImport(transactionManager, workflow, tags); + await this.tagService.setTagsForImport(transactionManager, workflow, tags); } if (workflow.active) { this.logger.info( diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts index 1ed0a3a1c6..ab81357daf 100644 --- a/packages/cli/src/controllers/tags.controller.ts +++ b/packages/cli/src/controllers/tags.controller.ts @@ -1,14 +1,10 @@ import { Request, Response, NextFunction } from 'express'; import config from '@/config'; import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; -import { type ITagWithCountDb } from '@/Interfaces'; -import type { TagEntity } from '@db/entities/TagEntity'; -import { TagRepository } from '@db/repositories'; -import { validateEntity } from '@/GenericHelpers'; +import { TagService } from '@/services/tag.service'; import { BadRequestError } from '@/ResponseHelper'; import { TagsRequest } from '@/requests'; import { Service } from 'typedi'; -import { ExternalHooks } from '@/ExternalHooks'; @Authorized() @RestController('/tags') @@ -16,10 +12,7 @@ import { ExternalHooks } from '@/ExternalHooks'; export class TagsController { private config = config; - constructor( - private tagsRepository: TagRepository, - private externalHooks: ExternalHooks, - ) {} + constructor(private tagService: TagService) {} // TODO: move this into a new decorator `@IfEnabled('workflowTagsDisabled')` @Middleware() @@ -29,61 +22,32 @@ export class TagsController { next(); } - // Retrieves all tags, with or without usage count @Get('/') - async getAll(req: TagsRequest.GetAll): Promise { - const { withUsageCount } = req.query; - if (withUsageCount === 'true') { - return this.tagsRepository - .find({ - select: ['id', 'name', 'createdAt', 'updatedAt'], - relations: ['workflowMappings'], - }) - .then((tags) => - tags.map(({ workflowMappings, ...rest }) => ({ - ...rest, - usageCount: workflowMappings.length, - })), - ); - } - - return this.tagsRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] }); + async getAll(req: TagsRequest.GetAll) { + return this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); } - // Creates a tag @Post('/') - async createTag(req: TagsRequest.Create): Promise { - const newTag = this.tagsRepository.create({ name: req.body.name.trim() }); + async createTag(req: TagsRequest.Create) { + const tag = this.tagService.toEntity({ name: req.body.name }); - await this.externalHooks.run('tag.beforeCreate', [newTag]); - await validateEntity(newTag); - - const tag = await this.tagsRepository.save(newTag); - await this.externalHooks.run('tag.afterCreate', [tag]); - return tag; + return this.tagService.save(tag, 'create'); } - // Updates a tag @Patch('/:id(\\w+)') - async updateTag(req: TagsRequest.Update): Promise { - const newTag = this.tagsRepository.create({ id: req.params.id, name: req.body.name.trim() }); + async updateTag(req: TagsRequest.Update) { + const newTag = this.tagService.toEntity({ id: req.params.id, name: req.body.name.trim() }); - await this.externalHooks.run('tag.beforeUpdate', [newTag]); - await validateEntity(newTag); - - const tag = await this.tagsRepository.save(newTag); - await this.externalHooks.run('tag.afterUpdate', [tag]); - return tag; + return this.tagService.save(newTag, 'update'); } @Authorized(['global', 'owner']) @Delete('/:id(\\w+)') async deleteTag(req: TagsRequest.Delete) { const { id } = req.params; - await this.externalHooks.run('tag.beforeDelete', [id]); - await this.tagsRepository.delete({ id }); - await this.externalHooks.run('tag.afterDelete', [id]); + await this.tagService.delete(id); + return true; } } diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index 7cc87e57ae..365971cefc 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -39,6 +39,7 @@ import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkfl import type { ExportableCredential } from './types/exportableCredential'; import { InternalHooks } from '@/InternalHooks'; import { TagRepository } from '@/databases/repositories'; + @Service() export class SourceControlService { private sshKeyName: string; diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index 2bd5881c8b..a35d18d2f3 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -492,15 +492,12 @@ export class SourceControlImportService { `A tag with the name ${tag.name} already exists locally.
Please either rename the local tag, or the remote one with the id ${tag.id} in the tags.json file.`, ); } - await this.tagRepository.upsert( - { - ...tag, - }, - { - skipUpdateIfNoValuesChanged: true, - conflictPaths: { id: true }, - }, - ); + + const tagCopy = this.tagRepository.create(tag); + await this.tagRepository.upsert(tagCopy, { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { id: true }, + }); }), ); diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts new file mode 100644 index 0000000000..5bf94da7de --- /dev/null +++ b/packages/cli/src/services/tag.service.ts @@ -0,0 +1,157 @@ +import { TagRepository } from '@/databases/repositories'; +import { Service } from 'typedi'; +import { validateEntity } from '@/GenericHelpers'; +import type { ITagToImport, ITagWithCountDb, IWorkflowToImport } from '@/Interfaces'; +import type { TagEntity } from '@/databases/entities/TagEntity'; +import type { EntityManager, FindManyOptions, FindOneOptions } from 'typeorm'; +import type { UpsertOptions } from 'typeorm/repository/UpsertOptions'; +import { ExternalHooks } from '@/ExternalHooks'; + +type GetAllResult = T extends { withUsageCount: true } ? ITagWithCountDb[] : TagEntity[]; + +@Service() +export class TagService { + constructor( + private externalHooks: ExternalHooks, + private tagRepository: TagRepository, + ) {} + + toEntity(attrs: { name: string; id?: string }) { + attrs.name = attrs.name.trim(); + + return this.tagRepository.create(attrs); + } + + async save(tag: TagEntity, actionKind: 'create' | 'update') { + await validateEntity(tag); + + const action = actionKind[0].toUpperCase() + actionKind.slice(1); + + await this.externalHooks.run(`tag.before${action}`, [tag]); + + const savedTag = this.tagRepository.save(tag); + + await this.externalHooks.run(`tag.after${action}`, [tag]); + + return savedTag; + } + + async delete(id: string) { + await this.externalHooks.run('tag.beforeDelete', [id]); + + const deleteResult = this.tagRepository.delete(id); + + await this.externalHooks.run('tag.afterDelete', [id]); + + return deleteResult; + } + + async findOne(options: FindOneOptions) { + return this.tagRepository.findOne(options); + } + + async findMany(options: FindManyOptions) { + return this.tagRepository.find(options); + } + + async upsert(tag: TagEntity, options: UpsertOptions) { + return this.tagRepository.upsert(tag, options); + } + + async getAll(options?: T): Promise> { + if (options?.withUsageCount) { + const allTags = await this.tagRepository.find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + relations: ['workflowMappings'], + }); + + return allTags.map(({ workflowMappings, ...rest }) => { + return { + ...rest, + usageCount: workflowMappings.length, + } as ITagWithCountDb; + }) as GetAllResult; + } + + return this.tagRepository.find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + }) as Promise>; + } + + /** + * Sort tags based on the order of the tag IDs in the request. + */ + sortByRequestOrder(tags: TagEntity[], { requestOrder }: { requestOrder: string[] }) { + const tagMap = tags.reduce>((acc, tag) => { + acc[tag.id] = tag; + return acc; + }, {}); + + return requestOrder.map((tagId) => tagMap[tagId]); + } + + /** + * Set tag IDs to use existing tags, creates a new tag if no matching tag could be found + */ + async setTagsForImport( + transactionManager: EntityManager, + workflow: IWorkflowToImport, + tags: TagEntity[], + ) { + if (!this.hasTags(workflow)) return; + + const workflowTags = workflow.tags; + const tagLookupPromises = []; + for (let i = 0; i < workflowTags.length; i++) { + if (workflowTags[i]?.name) { + const lookupPromise = this.findOrCreateTag(transactionManager, workflowTags[i], tags).then( + (tag) => { + workflowTags[i] = { + id: tag.id, + name: tag.name, + }; + }, + ); + tagLookupPromises.push(lookupPromise); + } + } + + await Promise.all(tagLookupPromises); + } + + private hasTags(workflow: IWorkflowToImport) { + return 'tags' in workflow && Array.isArray(workflow.tags) && workflow.tags.length > 0; + } + + private async findOrCreateTag( + transactionManager: EntityManager, + importTag: ITagToImport, + tagsEntities: TagEntity[], + ) { + // Assume tag is identical if createdAt date is the same to preserve a changed tag name + const identicalMatch = tagsEntities.find( + (existingTag) => + existingTag.id === importTag.id && + existingTag.createdAt && + importTag.createdAt && + existingTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), + ); + if (identicalMatch) { + return identicalMatch; + } + + const nameMatch = tagsEntities.find((existingTag) => existingTag.name === importTag.name); + if (nameMatch) { + return nameMatch; + } + + const created = await this.txCreateTag(transactionManager, importTag.name); + tagsEntities.push(created); + return created; + } + + private async txCreateTag(transactionManager: EntityManager, name: string) { + const tag = this.tagRepository.create({ name: name.trim() }); + return transactionManager.save(tag); + } +} diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index dc5a853883..6820f5963c 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -12,7 +12,6 @@ import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee'; import { ExternalHooks } from '@/ExternalHooks'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { LoggerProxy } from 'n8n-workflow'; -import * as TagHelpers from '@/TagHelpers'; import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee'; import type { IExecutionPushResponse } from '@/Interfaces'; import * as GenericHelpers from '@/GenericHelpers'; @@ -22,7 +21,7 @@ import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; -import { TagRepository } from '@/databases/repositories'; +import { TagService } from '@/services/tag.service'; // eslint-disable-next-line @typescript-eslint/naming-convention export const EEWorkflowController = express.Router(); @@ -137,7 +136,7 @@ EEWorkflowController.post( const { tags: tagIds } = req.body; if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { - newWorkflow.tags = await Container.get(TagRepository).find({ + newWorkflow.tags = await Container.get(TagService).findMany({ select: ['id', 'name'], where: { id: In(tagIds), @@ -188,7 +187,7 @@ EEWorkflowController.post( } if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { - savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { + savedWorkflow.tags = Container.get(TagService).sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, }); } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index ac53c2faed..a0874145a3 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -9,7 +9,6 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import type { IWorkflowResponse, IExecutionPushResponse } from '@/Interfaces'; import config from '@/config'; -import * as TagHelpers from '@/TagHelpers'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { validateEntity } from '@/GenericHelpers'; @@ -26,7 +25,7 @@ import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; -import { TagRepository } from '@/databases/repositories'; +import { TagService } from '@/services/tag.service'; export const workflowsController = express.Router(); @@ -65,7 +64,7 @@ workflowsController.post( const { tags: tagIds } = req.body; if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { - newWorkflow.tags = await Container.get(TagRepository).find({ + newWorkflow.tags = await Container.get(TagService).findMany({ select: ['id', 'name'], where: { id: In(tagIds), @@ -101,7 +100,7 @@ workflowsController.post( } if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { - savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { + savedWorkflow.tags = Container.get(TagService).sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, }); } diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 176f32bd77..8f249d830d 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -16,7 +16,7 @@ import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; -import * as TagHelpers from '@/TagHelpers'; +import { TagService } from '@/services/tag.service'; import type { ListQueryOptions, WorkflowRequest } from '@/requests'; import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; @@ -299,7 +299,7 @@ export class WorkflowsService { } if (updatedWorkflow.tags?.length && tagIds?.length) { - updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { + updatedWorkflow.tags = Container.get(TagService).sortByRequestOrder(updatedWorkflow.tags, { requestOrder: tagIds, }); }