diff --git a/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts b/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts new file mode 100644 index 00000000000..d707da2ede4 --- /dev/null +++ b/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts @@ -0,0 +1,15 @@ +import { BadRequestError } from './bad-request.error'; + +/** + * Error thrown when a workflow fails to deactivate, e.g. because a + * `workflow.deactivate` external hook rejected the operation. + */ +export class WorkflowDeactivationBadRequestError extends BadRequestError { + constructor( + message: string, + readonly meta: { description?: string } = {}, + ) { + super(message); + this.name = 'WorkflowDeactivationBadRequestError'; + } +} diff --git a/packages/cli/src/external-hooks.ts b/packages/cli/src/external-hooks.ts index 0476798e767..73613fb8011 100644 --- a/packages/cli/src/external-hooks.ts +++ b/packages/cli/src/external-hooks.ts @@ -71,6 +71,7 @@ type ExternalHooksMap = { 'workflow.create': [createdWorkflow: IWorkflowBase]; 'workflow.afterCreate': [createdWorkflow: IWorkflowBase]; 'workflow.activate': [updatedWorkflow: IWorkflowBase]; + 'workflow.deactivate': [deactivatedWorkflow: IWorkflowBase]; 'workflow.update': [updatedWorkflow: IWorkflowBase]; 'workflow.afterUpdate': [updatedWorkflow: IWorkflowBase]; 'workflow.delete': [workflowId: string]; diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index c0016df75f2..57de5c9b45d 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -22,6 +22,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { WorkflowActivationBadRequestError } from '@/errors/response-errors/workflow-activation-bad-request.error'; +import { WorkflowDeactivationBadRequestError } from '@/errors/response-errors/workflow-deactivation-bad-request.error'; import type { EventService } from '@/events/event.service'; import type { ExternalHooks } from '@/external-hooks'; import type { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service'; @@ -1258,6 +1259,49 @@ describe('WorkflowService', () => { // in-memory teardown is left to the leader, not run here expect(activeWorkflowManagerMock.remove).not.toHaveBeenCalled(); }); + + test('deactivation blocked by hook leaves the workflow published', async () => { + const workflow = makeWorkflowEntity({ activeVersionId: PREVIOUS_VERSION_ID }); + workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow); + + externalHooksMock.run.mockRejectedValue(new Error('Code freeze in effect')); + + const user = mock(); + + await expect(workflowService.deactivateWorkflow(user, WORKFLOW_ID)).rejects.toBeInstanceOf( + WorkflowDeactivationBadRequestError, + ); + + expect(workflow.active).toBe(true); + expect(workflow.activeVersionId).toBe(PREVIOUS_VERSION_ID); + expect(activeWorkflowManagerMock.remove).not.toHaveBeenCalled(); + expect(workflowRepositoryMock.update).not.toHaveBeenCalled(); + expect(workflowPublishHistoryRepositoryMock.addRecord).not.toHaveBeenCalled(); + }); + + test('hook receives the workflow being deactivated', async () => { + const workflow = makeWorkflowEntity({ activeVersionId: PREVIOUS_VERSION_ID }); + workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow); + + externalHooksMock.run.mockResolvedValue(undefined); + + const user = mock(); + + await workflowService.deactivateWorkflow(user, WORKFLOW_ID); + + expect(externalHooksMock.run).toHaveBeenCalledWith('workflow.deactivate', [workflow]); + }); + + test('does not run the hook when the workflow is already inactive', async () => { + const workflow = makeWorkflowEntity({ active: false, activeVersionId: null }); + workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow); + + const user = mock(); + + await workflowService.deactivateWorkflow(user, WORKFLOW_ID); + + expect(externalHooksMock.run).not.toHaveBeenCalled(); + }); }); describe('delete()', () => { diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 6719c62be9b..9fea407c23f 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -32,6 +32,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowActivationBadRequestError } from '@/errors/response-errors/workflow-activation-bad-request.error'; +import { WorkflowDeactivationBadRequestError } from '@/errors/response-errors/workflow-deactivation-bad-request.error'; import { WorkflowValidationError } from '@/errors/response-errors/workflow-validation.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { EventService } from '@/events/event.service'; @@ -983,6 +984,14 @@ export class WorkflowService { await this._detectConflicts(workflow, options.expectedChecksum); } + try { + await this.externalHooks.run('workflow.deactivate', [workflow]); + } catch (error) { + throw new WorkflowDeactivationBadRequestError(ensureError(error).message, { + description: getErrorDescription(error), + }); + } + if (this.globalConfig.workflows.useWorkflowPublicationService) { await this._unpublishViaOutbox(user, workflowId, deactivatedVersionId, workflow.updatedAt); } else {