UNPKG

n8n

Version:

n8n Workflow Automation Tool

412 lines • 23.6 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowReviewRequestService = void 0; const backend_common_1 = require("@n8n/backend-common"); const db_1 = require("@n8n/db"); const di_1 = require("@n8n/di"); const ensure_error_1 = require("@n8n/utils/errors/ensure-error"); const collaboration_service_1 = require("../../collaboration/collaboration.service"); const bad_request_error_1 = require("../../errors/response-errors/bad-request.error"); const conflict_error_1 = require("../../errors/response-errors/conflict.error"); const forbidden_error_1 = require("../../errors/response-errors/forbidden.error"); const not_found_error_1 = require("../../errors/response-errors/not-found.error"); const role_service_1 = require("../../services/role.service"); const workflow_finder_service_1 = require("../../workflows/workflow-finder.service"); const workflow_history_service_1 = require("../../workflows/workflow-history/workflow-history.service"); const workflow_service_1 = require("../../workflows/workflow.service"); const workflow_review_decision_eligibility_service_1 = require("./workflow-review-decision-eligibility.service"); const workflow_review_feature_gate_service_1 = require("./workflow-review-feature-gate.service"); const workflow_review_mapper_1 = require("./workflow-review.mapper"); function normalizeVersionDescription(description) { if (description === undefined) return undefined; return description.trim() || null; } let WorkflowReviewRequestService = class WorkflowReviewRequestService { constructor(logger, featureGate, workflowFinderService, workflowHistoryService, workflowHistoryRepository, workflowRepository, sharedWorkflowRepository, workflowPublishHistoryRepository, workflowReviewRequestRepository, workflowReviewRequestWorkflowRepository, workflowReviewRequestAuthorRepository, workflowReviewRequestReviewerRepository, userRepository, decisionEligibilityService, roleService, dbLockService, collaborationService, workflowService) { this.logger = logger; this.featureGate = featureGate; this.workflowFinderService = workflowFinderService; this.workflowHistoryService = workflowHistoryService; this.workflowHistoryRepository = workflowHistoryRepository; this.workflowRepository = workflowRepository; this.sharedWorkflowRepository = sharedWorkflowRepository; this.workflowPublishHistoryRepository = workflowPublishHistoryRepository; this.workflowReviewRequestRepository = workflowReviewRequestRepository; this.workflowReviewRequestWorkflowRepository = workflowReviewRequestWorkflowRepository; this.workflowReviewRequestAuthorRepository = workflowReviewRequestAuthorRepository; this.workflowReviewRequestReviewerRepository = workflowReviewRequestReviewerRepository; this.userRepository = userRepository; this.decisionEligibilityService = decisionEligibilityService; this.roleService = roleService; this.dbLockService = dbLockService; this.collaborationService = collaborationService; this.workflowService = workflowService; } async findEligibleReviewers(projectId, excludeUserId) { const [projectRoleSlugs, globalRoleSlugs] = await Promise.all([ this.roleService.rolesWithScope('project', ['workflow:publish']), this.roleService.rolesWithScope('global', ['workflow:publish']), ]); const users = await this.userRepository.findEligibleByProjectOrGlobalRoles({ projectId, projectRoleSlugs, globalRoleSlugs, }); return users .filter((user) => !user.isPending && user.id !== excludeUserId) .sort((a, b) => a.email.localeCompare(b.email)); } async list(user, query) { await this.featureGate.assertAvailable(); const workflow = await this.workflowFinderService.findWorkflowForUser(query.workflowId, user, [ 'workflow:read', ]); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } const [requests, count] = await this.workflowReviewRequestRepository.findRequestsForWorkflow(query.workflowId, { state: query.state, skip: query.skip, take: query.take }); return { count, data: await this.toWorkflowScopedItems(query.workflowId, requests), }; } async toWorkflowScopedItems(workflowId, requests) { const [decisionActors, publicationStates] = await Promise.all([ this.resolveDecisionActors(requests), this.resolveApprovedPublicationStates(workflowId, requests), ]); return requests.map((request) => ({ id: request.id, state: request.state, decision: request.decision, workflowVersionId: request.workflowVersionId, createdAt: request.createdAt.toISOString(), updatedAt: request.updatedAt.toISOString(), decisionBy: this.pickDecisionActor(request, decisionActors), approvedVersionPublicationState: this.pickApprovedPublicationState(request, publicationStates), })); } async resolveDecisionActors(requests) { const actorIds = [ ...new Set(requests.flatMap((request) => request.decision === 'changes_requested' && request.updatedById ? [request.updatedById] : [])), ]; if (actorIds.length === 0) { return new Map(); } const actors = await this.userRepository.findManyByIds(actorIds); return new Map(actors.map((actor) => [actor.id, (0, workflow_review_mapper_1.toEligibleReviewer)(actor)])); } pickDecisionActor(request, actors) { if (request.decision !== 'changes_requested' || !request.updatedById) { return null; } return actors.get(request.updatedById) ?? null; } async resolveApprovedPublicationStates(workflowId, requests) { const versionIds = [ ...new Set(requests.flatMap((request) => request.decision === 'approved' && request.workflowVersionId ? [request.workflowVersionId] : [])), ]; if (versionIds.length === 0) { return new Map(); } return await this.workflowPublishHistoryRepository.getVersionPublicationStates(workflowId, versionIds); } pickApprovedPublicationState(request, states) { if (request.decision !== 'approved') { return null; } if (!request.workflowVersionId) { return 'unknown'; } return states.get(request.workflowVersionId) ?? 'unknown'; } async getEligibleReviewers(user, query) { await this.featureGate.assertAvailable(); const workflow = await this.workflowFinderService.findWorkflowForUser(query.workflowId, user, [ 'workflow:publish', ]); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(query.workflowId); if (!project) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } const reviewers = await this.findEligibleReviewers(project.id, user.id); return { count: reviewers.length, data: reviewers.map(workflow_review_mapper_1.toEligibleReviewer), }; } async nameVersion(workflowId, versionId, name, description, ctx) { const affected = await this.workflowHistoryRepository.updateVersionMetadata({ workflowId, versionId, name, description }, ctx); if (affected === 0) { throw new bad_request_error_1.BadRequestError(`Version '${versionId}' does not exist for workflow '${workflowId}'`); } } async assertWorkflowStillReviewable(workflowId, expectedProjectId, ctx) { const workflow = await this.workflowRepository.findArchivedState(workflowId, ctx); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } if (workflow.isArchived) { throw new bad_request_error_1.BadRequestError(`The workflow '${workflowId}' is archived and cannot be submitted for review`); } const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflowId, ctx); if (project?.id !== expectedProjectId) { throw new conflict_error_1.ConflictError(`The workflow '${workflowId}' moved to another project and cannot be submitted for review here`, 'Retry from the project that now owns the workflow'); } } async create(user, dto) { const { workflowId, workflowVersionId, workflowVersionName, workflowVersionDescription } = dto.workflows[0]; const versionName = workflowVersionName.trim(); const versionDescription = normalizeVersionDescription(workflowVersionDescription); await this.featureGate.assertAvailable(); const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:publish', ]); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } if (workflow.isArchived) { throw new bad_request_error_1.BadRequestError(`The workflow '${workflowId}' is archived and cannot be submitted for review`); } const version = await this.workflowHistoryService.findVersion(workflowId, workflowVersionId); if (!version) { throw new bad_request_error_1.BadRequestError(`Version '${workflowVersionId}' does not exist for workflow '${workflowId}'`); } const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflowId); if (!project) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } const reviewerUserIds = [...new Set(dto.reviewerUserIds ?? [])]; if (reviewerUserIds.length > 0) { if (reviewerUserIds.includes(user.id)) { throw new bad_request_error_1.BadRequestError('You cannot assign yourself as a reviewer'); } const eligibleIds = new Set((await this.findEligibleReviewers(project.id, user.id)).map((reviewer) => reviewer.id)); const ineligibleIds = reviewerUserIds.filter((id) => !eligibleIds.has(id)); if (ineligibleIds.length > 0) { throw new bad_request_error_1.BadRequestError(`These users are not eligible to review this workflow: ${ineligibleIds.join(', ')}`); } } const request = await this.dbLockService.withLockContext(1004, async (ctx) => { await this.assertWorkflowStillReviewable(workflowId, project.id, ctx); const existing = await this.workflowReviewRequestRepository.findOpenRequestForWorkflow(workflowId, ctx); if (existing) { throw new conflict_error_1.ConflictError('An open review request already exists for this workflow', 'Update the existing review request instead of creating a new one', { workflowReviewRequestId: existing.id }); } const created = await this.workflowReviewRequestRepository.createRequest({ projectId: project.id, title: dto.title, description: dto.description ?? null, createdById: user.id, }, ctx); await this.workflowReviewRequestWorkflowRepository.createWorkflowRow({ workflowReviewRequestId: created.id, workflowId, workflowVersionId, }, ctx); await this.nameVersion(workflowId, workflowVersionId, versionName, versionDescription, ctx); await this.workflowReviewRequestAuthorRepository.addAuthor({ workflowReviewRequestId: created.id, userId: user.id }, ctx); if (reviewerUserIds.length > 0) { await this.workflowReviewRequestReviewerRepository.addReviewers({ workflowReviewRequestId: created.id, userIds: reviewerUserIds }, ctx); } return created; }); this.broadcastReviewStateChanged(workflowId); return this.toSummary(request, workflowVersionId); } async updateVersion(user, workflowReviewRequestId, dto) { await this.featureGate.assertAvailable(); const request = await this.workflowReviewRequestRepository.findById(workflowReviewRequestId, {}); if (!request) { throw new not_found_error_1.NotFoundError('Could not find review request'); } const workflowRows = await this.workflowReviewRequestWorkflowRepository.findByRequestId(workflowReviewRequestId, {}); const workflowRow = workflowRows.find((row) => row.workflowId === dto.workflowId); if (!workflowRow) { throw new not_found_error_1.NotFoundError('Could not find review request'); } const workflow = await this.workflowFinderService.findWorkflowForUser(dto.workflowId, user, [ 'workflow:publish', ]); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } if (workflow.isArchived) { throw new bad_request_error_1.BadRequestError(`The workflow '${dto.workflowId}' is archived and its review cannot be updated`); } this.assertRequestUpdatable(request); const version = await this.workflowHistoryService.findVersion(dto.workflowId, dto.workflowVersionId); if (!version) { throw new bad_request_error_1.BadRequestError(`Version '${dto.workflowVersionId}' does not exist for workflow '${dto.workflowId}'`); } const versionName = dto.workflowVersionName.trim(); const versionDescription = normalizeVersionDescription(dto.workflowVersionDescription); const metadataChanged = (current) => versionName !== current.name || (versionDescription !== undefined && versionDescription !== current.description); if (workflowRow.workflowVersionId === dto.workflowVersionId) { if (metadataChanged(version)) { await this.nameVersion(dto.workflowId, dto.workflowVersionId, versionName, versionDescription, {}); } return this.toSummary(request, workflowRow.workflowVersionId); } const { request: updated, changed } = await this.dbLockService.withLockContext(1004, async (ctx) => { const current = await this.workflowReviewRequestRepository.findById(workflowReviewRequestId, ctx); if (!current) { throw new not_found_error_1.NotFoundError('Could not find review request'); } this.assertRequestUpdatable(current); const currentRows = await this.workflowReviewRequestWorkflowRepository.findByRequestId(workflowReviewRequestId, ctx); const currentRow = currentRows.find((row) => row.workflowId === dto.workflowId); if (!currentRow) { throw new not_found_error_1.NotFoundError('Could not find review request'); } if (currentRow.workflowVersionId === dto.workflowVersionId) { if (metadataChanged(version)) { await this.nameVersion(dto.workflowId, dto.workflowVersionId, versionName, versionDescription, ctx); } return { request: current, changed: false }; } await this.workflowReviewRequestWorkflowRepository.updateWorkflowVersion({ workflowReviewRequestId, workflowId: dto.workflowId, workflowVersionId: dto.workflowVersionId, }, ctx); await this.nameVersion(dto.workflowId, dto.workflowVersionId, versionName, versionDescription, ctx); current.decision = 'pending'; current.updatedById = user.id; const saved = await this.workflowReviewRequestRepository.saveRequest(current, ctx); await this.workflowReviewRequestAuthorRepository.addAuthorIfMissing({ workflowReviewRequestId, userId: user.id }, ctx); return { request: saved, changed: true }; }); if (changed) { this.broadcastReviewStateChanged(dto.workflowId); } return this.toSummary(updated, dto.workflowVersionId); } async decide(user, workflowReviewRequestId, dto) { await this.featureGate.assertAvailable(); const request = await this.workflowReviewRequestRepository.findById(workflowReviewRequestId, {}); if (!request) { throw new not_found_error_1.NotFoundError('Could not find review request'); } const workflowRows = await this.workflowReviewRequestWorkflowRepository.findByRequestId(workflowReviewRequestId, {}); const workflowRow = workflowRows[0]; if (!workflowRow) { throw new not_found_error_1.NotFoundError('Could not find review request'); } const workflow = await this.workflowFinderService.findWorkflowForUser(workflowRow.workflowId, user, ['workflow:publish']); if (!workflow) { throw new not_found_error_1.NotFoundError('Could not find workflow'); } this.assertRequestUpdatable(request); const hasAdminOverride = await this.decisionEligibilityService.hasAdminOverride(user, request.projectId); const isAuthor = await this.workflowReviewRequestAuthorRepository.isAuthor({ workflowReviewRequestId, userId: user.id }, {}); this.assertDecisionAllowed(isAuthor, hasAdminOverride); const { request: saved, pinnedVersionId } = await this.dbLockService.withLockContext(1004, async (ctx) => { const current = await this.workflowReviewRequestRepository.findById(workflowReviewRequestId, ctx); if (!current) { throw new not_found_error_1.NotFoundError('Could not find review request'); } this.assertRequestUpdatable(current); const isAuthorNow = await this.workflowReviewRequestAuthorRepository.isAuthor({ workflowReviewRequestId, userId: user.id }, ctx); this.assertDecisionAllowed(isAuthorNow, hasAdminOverride); const currentRows = await this.workflowReviewRequestWorkflowRepository.findByRequestId(workflowReviewRequestId, ctx); const currentRow = currentRows.find((row) => row.workflowId === workflowRow.workflowId); if (!currentRow) { throw new not_found_error_1.NotFoundError('Could not find review request'); } current.decision = dto.decision; current.updatedById = user.id; if (dto.decision === 'approved') { current.state = 'closed'; current.closedById = user.id; current.approvedAt = new Date(); } const savedRequest = await this.workflowReviewRequestRepository.saveRequest(current, ctx); return { request: savedRequest, pinnedVersionId: currentRow.workflowVersionId }; }); this.broadcastReviewStateChanged(workflowRow.workflowId); const summary = this.toSummary(saved, pinnedVersionId); if (dto.decision !== 'approved') { return summary; } return { ...summary, autoPublish: await this.publishApprovedVersion(user, workflowRow.workflowId, pinnedVersionId), }; } async publishApprovedVersion(user, workflowId, pinnedVersionId) { if (pinnedVersionId === null) { this.logger.warn('Cannot publish approved review: the pinned version was pruned', { workflowId, }); return { status: 'failed', message: 'The reviewed workflow version no longer exists' }; } try { await this.workflowService.activateWorkflow(user, workflowId, { versionId: pinnedVersionId, source: 'review-approval', }); } catch (error) { this.logger.error('Failed to publish workflow after review approval', { workflowId, pinnedVersionId, error, }); return { status: 'failed', message: (0, ensure_error_1.ensureError)(error).message }; } this.collaborationService .broadcastWorkflowUpdate(workflowId, user.id) .catch((error) => this.logger.warn('Failed to broadcast workflow update', { workflowId, error })); return { status: 'published' }; } assertDecisionAllowed(isAuthor, hasAdminOverride) { if (isAuthor && !hasAdminOverride) { throw new forbidden_error_1.ForbiddenError('Authors cannot decide on their own review request'); } } broadcastReviewStateChanged(workflowId) { this.collaborationService .broadcastWorkflowReviewStateChanged(workflowId) .catch((error) => this.logger.warn('Failed to broadcast review state change', { workflowId, error })); } assertRequestUpdatable(request) { if (request.state === 'closed' || request.decision === 'approved') { throw new conflict_error_1.ConflictError('The review request is no longer open'); } } toSummary(request, workflowVersionId) { return { id: request.id, state: request.state, decision: request.decision, workflowVersionId, createdAt: request.createdAt.toISOString(), updatedAt: request.updatedAt.toISOString(), }; } }; exports.WorkflowReviewRequestService = WorkflowReviewRequestService; exports.WorkflowReviewRequestService = WorkflowReviewRequestService = __decorate([ (0, di_1.Service)(), __metadata("design:paramtypes", [backend_common_1.Logger, workflow_review_feature_gate_service_1.WorkflowReviewFeatureGate, workflow_finder_service_1.WorkflowFinderService, workflow_history_service_1.WorkflowHistoryService, db_1.WorkflowHistoryRepository, db_1.WorkflowRepository, db_1.SharedWorkflowRepository, db_1.WorkflowPublishHistoryRepository, db_1.WorkflowReviewRequestRepository, db_1.WorkflowReviewRequestWorkflowRepository, db_1.WorkflowReviewRequestAuthorRepository, db_1.WorkflowReviewRequestReviewerRepository, db_1.UserRepository, workflow_review_decision_eligibility_service_1.WorkflowReviewDecisionEligibilityService, role_service_1.RoleService, db_1.DbLockService, collaboration_service_1.CollaborationService, workflow_service_1.WorkflowService]) ], WorkflowReviewRequestService); //# sourceMappingURL=workflow-review-request.service.js.map