UNPKG

n8n

Version:

n8n Workflow Automation Tool

373 lines • 20 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 permissions_1 = require("@n8n/permissions"); const collaboration_service_1 = require("../../collaboration/collaboration.service"); const workflow_reviews_1 = require("../../constants/workflow-reviews"); 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 project_service_ee_1 = require("../../services/project.service.ee"); const role_service_1 = require("../../services/role.service"); const workflow_review_policy_service_1 = require("../../services/workflow-review-policy.service"); const workflow_finder_service_1 = require("../../workflows/workflow-finder.service"); const workflow_history_service_1 = require("../../workflows/workflow-history/workflow-history.service"); let WorkflowReviewRequestService = class WorkflowReviewRequestService { constructor(logger, workflowReviewPolicyService, workflowFinderService, workflowHistoryService, sharedWorkflowRepository, workflowReviewRequestRepository, workflowReviewRequestWorkflowRepository, workflowReviewRequestAuthorRepository, workflowReviewRequestReviewerRepository, userRepository, roleService, projectService, licenseState, dbLockService, collaborationService) { this.logger = logger; this.workflowReviewPolicyService = workflowReviewPolicyService; this.workflowFinderService = workflowFinderService; this.workflowHistoryService = workflowHistoryService; this.sharedWorkflowRepository = sharedWorkflowRepository; this.workflowReviewRequestRepository = workflowReviewRequestRepository; this.workflowReviewRequestWorkflowRepository = workflowReviewRequestWorkflowRepository; this.workflowReviewRequestAuthorRepository = workflowReviewRequestAuthorRepository; this.workflowReviewRequestReviewerRepository = workflowReviewRequestReviewerRepository; this.userRepository = userRepository; this.roleService = roleService; this.projectService = projectService; this.licenseState = licenseState; this.dbLockService = dbLockService; this.collaborationService = collaborationService; } 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) { const policy = await this.workflowReviewPolicyService.get(); if (!policy.enabled) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are not enabled for this instance'); } 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: requests.map((request) => ({ id: request.id, state: request.state, decision: request.decision, workflowVersionId: request.workflowVersionId, createdAt: request.createdAt.toISOString(), updatedAt: request.updatedAt.toISOString(), })), }; } async getEligibleReviewers(user, query) { const policy = await this.workflowReviewPolicyService.get(); if (!policy.enabled) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are not enabled for this instance'); } 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((reviewer) => this.toEligibleReviewer(reviewer)), }; } toEligibleReviewer(user) { return { id: user.id, email: user.email, firstName: user.firstName ?? null, lastName: user.lastName ?? null, }; } async create(user, dto) { const { workflowId, workflowVersionId } = dto.workflows[0]; const policy = await this.workflowReviewPolicyService.get(); if (!policy.enabled) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are not enabled for this instance'); } 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.withLock(1004, async (tx) => { const existing = await this.workflowReviewRequestRepository.findOpenRequestForWorkflow(workflowId, tx); 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, }, tx); await this.workflowReviewRequestWorkflowRepository.createWorkflowRow({ workflowReviewRequestId: created.id, workflowId, workflowVersionId, }, tx); await this.workflowReviewRequestAuthorRepository.addAuthor({ workflowReviewRequestId: created.id, userId: user.id }, tx); if (reviewerUserIds.length > 0) { await this.workflowReviewRequestReviewerRepository.addReviewers({ workflowReviewRequestId: created.id, userIds: reviewerUserIds }, tx); } return created; }); this.broadcastReviewStateChanged(workflowId); return this.toSummary(request, workflowVersionId); } async updateVersion(user, workflowReviewRequestId, dto) { const policy = await this.workflowReviewPolicyService.get(); if (!policy.enabled) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are not enabled for this instance'); } 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}'`); } if (workflowRow.workflowVersionId === dto.workflowVersionId) { return this.toSummary(request, workflowRow.workflowVersionId); } const { request: updated, changed } = await this.dbLockService.withLock(1004, async (tx) => { const current = await this.workflowReviewRequestRepository.findById(workflowReviewRequestId, tx); if (!current) { throw new not_found_error_1.NotFoundError('Could not find review request'); } this.assertRequestUpdatable(current); const currentRows = await this.workflowReviewRequestWorkflowRepository.findByRequestId(workflowReviewRequestId, tx); 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) { return { request: current, changed: false }; } await this.workflowReviewRequestWorkflowRepository.updateWorkflowVersion({ workflowReviewRequestId, workflowId: dto.workflowId, workflowVersionId: dto.workflowVersionId, }, tx); current.decision = 'pending'; current.updatedById = user.id; const saved = await tx.save(current); await this.workflowReviewRequestAuthorRepository.addAuthorIfMissing({ workflowReviewRequestId, userId: user.id }, tx); return { request: saved, changed: true }; }); if (changed) { this.broadcastReviewStateChanged(dto.workflowId); } return this.toSummary(updated, dto.workflowVersionId); } 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(), }; } async listForInbox(user, query) { await this.assertFeatureAvailable(); const projectIds = await this.resolveAccessibleProjectIds(user); const { limit } = query; const rows = await this.workflowReviewRequestRepository.findManyForInbox({ projectIds, requesterId: user.id, state: query.state ?? 'open', limit: limit + 1, cursor: query.cursor ? this.decodeInboxCursor(query.cursor) : undefined, }); const hasMore = rows.length > limit; const data = rows.slice(0, limit); const lastRow = data.at(-1); const nextCursor = hasMore && lastRow ? this.encodeInboxCursor(lastRow) : null; const requestIds = data.map((row) => row.id); const [linkedWorkflowByRequestId, reviewerRows] = await Promise.all([ this.workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds(requestIds), this.workflowReviewRequestReviewerRepository.findByRequestIds(requestIds), ]); const participantsByRequestId = await this.hydrateParticipants(data, reviewerRows); return { data: data.map((row) => { const { requester, reviewers } = participantsByRequestId.get(row.id) ?? { requester: null, reviewers: [], }; return this.toInboxItem(row, linkedWorkflowByRequestId.get(row.id) ?? null, requester, reviewers); }), nextCursor, hasMore, }; } async hydrateParticipants(rows, reviewerRows) { const reviewerIdsByRequestId = new Map(); for (const { workflowReviewRequestId, userId } of reviewerRows) { const ids = reviewerIdsByRequestId.get(workflowReviewRequestId) ?? []; ids.push(userId); reviewerIdsByRequestId.set(workflowReviewRequestId, ids); } const userIds = new Set([ ...rows.map((row) => row.createdById).filter((id) => id !== null), ...reviewerRows.map((row) => row.userId), ]); const usersById = new Map(); if (userIds.size > 0) { for (const user of await this.userRepository.findManyByIds([...userIds])) { usersById.set(user.id, this.toEligibleReviewer(user)); } } return new Map(rows.map((row) => [ row.id, { requester: row.createdById ? (usersById.get(row.createdById) ?? null) : null, reviewers: (reviewerIdsByRequestId.get(row.id) ?? []) .map((userId) => usersById.get(userId)) .filter((reviewer) => reviewer !== undefined), }, ])); } async getInboxSummaryForUser(user) { await this.assertFeatureAvailable(); const projectIds = await this.resolveAccessibleProjectIds(user); return await this.workflowReviewRequestRepository.countByStateForInbox({ projectIds, requesterId: user.id, }); } async assertFeatureAvailable() { if (!(0, workflow_reviews_1.isWorkflowReviewsFeatureAvailable)(this.licenseState.isWorkflowReviewsLicensed())) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are not available on this instance'); } const policy = await this.workflowReviewPolicyService.get(); if (!policy.enabled) { throw new forbidden_error_1.ForbiddenError('Workflow reviews are disabled on this instance'); } } async resolveAccessibleProjectIds(user) { if ((0, permissions_1.hasGlobalScope)(user, 'workflow:publish')) { return null; } return await this.projectService.getProjectIdsWithScope(user, ['workflow:publish']); } encodeInboxCursor(row) { return Buffer.from(`${row.createdAt.toISOString()}|${row.id}`, 'utf8').toString('base64url'); } decodeInboxCursor(cursor) { const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); const separatorIndex = decoded.indexOf('|'); if (separatorIndex === -1) { throw new bad_request_error_1.BadRequestError('Invalid pagination cursor'); } const createdAt = new Date(decoded.slice(0, separatorIndex)); const id = decoded.slice(separatorIndex + 1); if (id.length === 0 || Number.isNaN(createdAt.getTime())) { throw new bad_request_error_1.BadRequestError('Invalid pagination cursor'); } return { createdAt, id }; } toInboxItem(entity, linkedWorkflow, requester, reviewers) { return { id: entity.id, projectId: entity.projectId, title: entity.title, workflowName: linkedWorkflow?.workflowName ?? null, workflowVersionId: linkedWorkflow?.workflowVersionId ?? null, decision: entity.decision, state: entity.state, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), requester, reviewers, }; } }; exports.WorkflowReviewRequestService = WorkflowReviewRequestService; exports.WorkflowReviewRequestService = WorkflowReviewRequestService = __decorate([ (0, di_1.Service)(), __metadata("design:paramtypes", [backend_common_1.Logger, workflow_review_policy_service_1.WorkflowReviewPolicyService, workflow_finder_service_1.WorkflowFinderService, workflow_history_service_1.WorkflowHistoryService, db_1.SharedWorkflowRepository, db_1.WorkflowReviewRequestRepository, db_1.WorkflowReviewRequestWorkflowRepository, db_1.WorkflowReviewRequestAuthorRepository, db_1.WorkflowReviewRequestReviewerRepository, db_1.UserRepository, role_service_1.RoleService, project_service_ee_1.ProjectService, backend_common_1.LicenseState, db_1.DbLockService, collaboration_service_1.CollaborationService]) ], WorkflowReviewRequestService); //# sourceMappingURL=workflow-review-request.service.js.map