diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php index 8a0f6de9359..19a66fd8c0d 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php @@ -23,6 +23,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; @@ -66,6 +67,9 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraph if ($row === false) { throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); } + if ($row['currentContentStreamId'] === null) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspaceName); + } $currentContentStreamId = ContentStreamId::fromString($row['currentContentStreamId']); return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $currentContentStreamId); } @@ -144,7 +148,7 @@ private function getBasicWorkspaceQuery(): QueryBuilder return $queryBuilder ->select('ws.name, ws.baseWorkspaceName, ws.currentContentStreamId, cs.hasChanges, cs.sourceContentStreamVersion = scs.version as upToDateWithBase') ->from($this->tableNames->workspace(), 'ws') - ->join('ws', $this->tableNames->contentStream(), 'cs', 'cs.id = ws.currentcontentstreamid') + ->leftJoin('ws', $this->tableNames->contentStream(), 'cs', 'cs.id = ws.currentcontentstreamid') ->leftJoin('cs', $this->tableNames->contentStream(), 'scs', 'scs.id = cs.sourceContentStreamId'); } @@ -153,9 +157,13 @@ private function getBasicWorkspaceQuery(): QueryBuilder */ private static function workspaceFromDatabaseRow(array $row): Workspace { + $workspaceName = WorkspaceName::fromString($row['name']); $baseWorkspaceName = $row['baseWorkspaceName'] !== null ? WorkspaceName::fromString($row['baseWorkspaceName']) : null; - if ($baseWorkspaceName === null) { + if ($row['currentContentStreamId'] === null) { + // no currentContentStreamId, workspace is deactivated + $status = WorkspaceStatus::DEACTIVATED; + } elseif ($baseWorkspaceName === null) { // no base workspace, a root is always up-to-date $status = WorkspaceStatus::UP_TO_DATE; } elseif ($row['upToDateWithBase'] === 1) { @@ -167,9 +175,9 @@ private static function workspaceFromDatabaseRow(array $row): Workspace } return Workspace::create( - WorkspaceName::fromString($row['name']), + $workspaceName, $baseWorkspaceName, - ContentStreamId::fromString($row['currentContentStreamId']), + $row['currentContentStreamId'] ? ContentStreamId::fromString($row['currentContentStreamId']) : null, $status, $baseWorkspaceName === null ? false diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index fc713cc4c0b..d835bc46191 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -51,6 +51,8 @@ use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged; @@ -167,9 +169,11 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void WorkspaceBaseWorkspaceWasChanged::class => $this->whenWorkspaceBaseWorkspaceWasChanged($event), WorkspaceRebaseFailed::class => $this->whenWorkspaceRebaseFailed($event), WorkspaceWasCreated::class => $this->whenWorkspaceWasCreated($event), + WorkspaceWasActivated::class => $this->whenWorkspaceWasActivated($event), WorkspaceWasDiscarded::class => $this->whenWorkspaceWasDiscarded($event), WorkspaceWasPublished::class => $this->whenWorkspaceWasPublished($event), WorkspaceWasRebased::class => $this->whenWorkspaceWasRebased($event), + WorkspaceWasDeactivated::class => $this->whenWorkspaceWasDeactivated($event), WorkspaceWasRemoved::class => $this->whenWorkspaceWasRemoved($event), default => null, }; @@ -683,6 +687,16 @@ private function whenWorkspaceBaseWorkspaceWasChanged(WorkspaceBaseWorkspaceWasC $this->updateBaseWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); } + private function whenWorkspaceWasActivated(WorkspaceWasActivated $event): void + { + $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); + } + + private function whenWorkspaceWasDeactivated(WorkspaceWasDeactivated $event): void + { + $this->updateWorkspaceContentStreamId($event->workspaceName, null); + } + private function whenWorkspaceRebaseFailed(WorkspaceRebaseFailed $event): void { // legacy handling: diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php index 0aaa056c601..2e5baf0b2c6 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php @@ -109,7 +109,7 @@ private function createWorkspaceTable(AbstractPlatform $platform): Table $workspaceTable = self::createTable($this->tableNames->workspace(), [ DbalSchemaFactory::columnForWorkspaceName('name', $platform)->setNotnull(true), DbalSchemaFactory::columnForWorkspaceName('baseWorkspaceName', $platform)->setNotnull(false), - DbalSchemaFactory::columnForContentStreamId('currentContentStreamId', $platform)->setNotNull(true), + DbalSchemaFactory::columnForContentStreamId('currentContentStreamId', $platform)->setNotNull(false), ]); $workspaceTable->addUniqueIndex(['currentContentStreamId']); diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php index 882e4469f96..19b556a1c9d 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php @@ -45,10 +45,10 @@ private function updateBaseWorkspace(WorkspaceName $workspaceName, WorkspaceName private function updateWorkspaceContentStreamId( WorkspaceName $workspaceName, - ContentStreamId $contentStreamId, + ?ContentStreamId $contentStreamId, ): void { $this->dbal->update($this->tableNames->workspace(), [ - 'currentContentStreamId' => $contentStreamId->value, + 'currentContentStreamId' => $contentStreamId?->value, ], [ 'name' => $workspaceName->value ]); diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature new file mode 100644 index 00000000000..812592b6dd0 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature @@ -0,0 +1,73 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Deactivate/Activate workspace constraints + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Scenario: Deactivating the workspace is not allowed for workspaces with other workspaces depending on it + When the command DeactivateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "live" | + + Then the last command should have thrown an exception of type "WorkspaceHasWorkspacesDependingOnIt" + + Scenario: Activating the workspace is not allowed for active workspaces + When the command ActivateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "new-user-cs-identifier" | + + Then the last command should have thrown an exception of type "WorkspaceIsActivated" with code 1766069245 and message: + """ + The workspace "user-test" is activated + """ + + Scenario: Deactivating the workspace is not allowed if there are pending changes + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "holy-nody" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "New node in shared"} | + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to node user-cs-identifier;holy-nody;{} + + When the command DeactivateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + + Then the last command should have thrown an exception of type "WorkspaceContainsPublishableChanges" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature new file mode 100644 index 00000000000..653bc2def1c --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature @@ -0,0 +1,76 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Deactivate and Activate a Workspace + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Original text"} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Scenario: Deactivating a workspace with no pending changes + When the command DeactivateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + Then workspace user-test has status DEACTIVATED + + Scenario: Deactivating an already deactivated Workspace fails + And the command DeactivateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + When the command DeactivateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + Then the last command should have thrown an exception of type "WorkspaceIsDeactivated" with code 1765977861 and message: + """ + The workspace "user-test" is deactivated + """ + + Scenario: Activating a deactivated workspace + When the command DeactivateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + And the command ActivateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-new-cs-identifier" | + Then workspace user-test has status UP_TO_DATE + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-new-cs-identifier;nody-mc-nodeface;{} + diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php index 028700f972f..6e079993a13 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php @@ -27,6 +27,8 @@ use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged; @@ -107,6 +109,8 @@ public static function create(): self WorkspaceWasRemoved::class, WorkspaceOwnerWasChanged::class, WorkspaceBaseWorkspaceWasChanged::class, + WorkspaceWasActivated::class, + WorkspaceWasDeactivated::class, ]; $fullClassNameToShortEventType = []; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php index 571b24f7432..bf04f05c125 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php @@ -14,14 +14,19 @@ use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceContainsPublishableChanges; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasWorkspacesDependingOnIt; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated; +use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; trait WorkspaceConstraintChecks { /** * @throws WorkspaceDoesNotExist + * @phpstan-return Workspace */ private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace { @@ -33,9 +38,31 @@ private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingD return $workspace; } + /** + * @throws WorkspaceDoesNotExist|WorkspaceIsDeactivated + * @phpstan-return object{ currentContentStreamId: ContentStreamId, status: WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED } & Workspace + */ + private function requireActiveWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace + { + $workspace = $this->requireWorkspace($workspaceName, $commandHandlingDependencies); + if (!$workspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspaceName); + } + + // DANGEROUS error of type return.type ignored + // This is done due to phpstan being unable to deduce that the returned workspace actually fulfills all + // required conditions. This is guaranteed by `$workspace->isActive()` returning true: + // isActive() is annotated with + // @ phpstan-assert-if-true ContentStreamId $this->currentContentStreamId + // @ phpstan-assert-if-true WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED $this->status + // which are the same guarantees this method makes, but in a syntactical different way. + return $workspace; // @phpstan-ignore return.type + } + /** * @throws WorkspaceHasNoBaseWorkspaceName * @throws BaseWorkspaceDoesNotExist + * @phpstan-return object{ currentContentStreamId: ContentStreamId, status: WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED } & Workspace */ private function requireBaseWorkspace(Workspace $workspace, CommandHandlingDependencies $commandHandlingDependencies): Workspace { @@ -45,13 +72,19 @@ private function requireBaseWorkspace(Workspace $workspace, CommandHandlingDepen $baseWorkspace = $commandHandlingDependencies->findWorkspaceByName($workspace->baseWorkspaceName); if (is_null($baseWorkspace)) { throw BaseWorkspaceDoesNotExist::butWasSupposedTo($workspace->workspaceName); + } elseif (!$baseWorkspace->isActive()) { + // should never happen! + // TODO: if this happens, something is seriously wrong in the database, handle differently? + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->baseWorkspaceName); } - return $baseWorkspace; + + // @phpstan-ignore-next-line + return $baseWorkspace; // @phpstan-ignore return.type } private function requireWorkspaceToBeRootOrRootBasedForDimensionAdjustment(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void { - $workspace = $this->requireWorkspace($workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($workspaceName, $commandHandlingDependencies); if (!$workspace->isRootWorkspace()) { $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); if (!$baseWorkspace->isRootWorkspace()) { @@ -60,6 +93,23 @@ private function requireWorkspaceToBeRootOrRootBasedForDimensionAdjustment(Works } } + private function requireWorkspaceToNotBeBaseForAnyOtherWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void + { + $workspaces = $commandHandlingDependencies->findAllWorkspaces(); + $conflictingWorkspaceNames = []; + foreach ($workspaces as $workspace) { + if ($workspace->baseWorkspaceName === $workspaceName) { + $conflictingWorkspaceNames[] = $workspace->workspaceName; + } + } + if ($conflictingWorkspaceNames !== []) { + throw WorkspaceHasWorkspacesDependingOnIt::butWasNotSupposedTo( + $workspaceName, + ...$conflictingWorkspaceNames + ); + } + } + private static function requireNoWorkspaceToHaveChanges(Workspaces $workspaces): void { $conflictingWorkspaceNames = []; diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php new file mode 100644 index 00000000000..db6f973cbe6 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php @@ -0,0 +1,50 @@ +workspaceName; + } + + public static function fromArray(array $values): self + { + return new self( + WorkspaceName::fromString($values['workspaceName']), + ContentStreamId::fromString($values['newContentStreamId']), + ); + } + + public function jsonSerialize(): array + { + return [ + 'workspaceName' => $this->workspaceName, + 'newContentStreamId' => $this->newContentStreamId + ]; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php new file mode 100644 index 00000000000..dff0bb55f45 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php @@ -0,0 +1,44 @@ +workspaceName; + } + + public static function fromArray(array $values): self + { + return new self(WorkspaceName::fromString($values['workspaceName'])); + } + + public function jsonSerialize(): array + { + return ['workspaceName' => $this->workspaceName]; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index 56e6048adc9..a139c0b5f4b 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -30,6 +30,10 @@ use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened; use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Event\ContentStreamWasCreated; use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; @@ -60,6 +64,8 @@ use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceContainsPublishableChanges; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsActivated; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -99,12 +105,14 @@ public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $comm /** @phpstan-ignore-next-line */ return match ($command::class) { CreateWorkspace::class => $this->handleCreateWorkspace($command, $commandHandlingDependencies), + ActivateWorkspace::class => $this->handleActivateWorkspace($command, $commandHandlingDependencies), CreateRootWorkspace::class => $this->handleCreateRootWorkspace($command, $commandHandlingDependencies), PublishWorkspace::class => $this->handlePublishWorkspace($command, $commandHandlingDependencies), RebaseWorkspace::class => $this->handleRebaseWorkspace($command, $commandHandlingDependencies), PublishIndividualNodesFromWorkspace::class => $this->handlePublishIndividualNodesFromWorkspace($command, $commandHandlingDependencies), DiscardIndividualNodesFromWorkspace::class => $this->handleDiscardIndividualNodesFromWorkspace($command, $commandHandlingDependencies), DiscardWorkspace::class => $this->handleDiscardWorkspace($command, $commandHandlingDependencies), + DeactivateWorkspace::class => $this->handleDeactivateWorkspace($command, $commandHandlingDependencies), DeleteWorkspace::class => $this->handleDeleteWorkspace($command, $commandHandlingDependencies), ChangeBaseWorkspace::class => $this->handleChangeBaseWorkspace($command, $commandHandlingDependencies), }; @@ -129,6 +137,9 @@ private function handleCreateWorkspace( $command->workspaceName->value ), 1513890708); } + if (!$baseWorkspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($command->baseWorkspaceName); + } $sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies); @@ -192,7 +203,7 @@ private function handlePublishWorkspace( PublishWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); if (!$workspace->hasPublishableChanges()) { throw WorkspaceCommandSkipped::becauseWorkspaceToPublishIsEmpty($command->workspaceName); @@ -284,6 +295,13 @@ private function rebaseWorkspaceWithoutChanges( Version $baseWorkspaceContentStreamVersion, ContentStreamId $newContentStreamId ): \Generator { + if (!$baseWorkspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($baseWorkspace->workspaceName); + } + if (!$workspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->workspaceName); + } + yield $this->forkContentStream( $newContentStreamId, $baseWorkspace->currentContentStreamId, @@ -340,7 +358,7 @@ private function handleRebaseWorkspace( RebaseWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); @@ -443,7 +461,7 @@ private function handlePublishIndividualNodesFromWorkspace( PublishIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); if (!$workspace->hasPublishableChanges()) { @@ -567,7 +585,7 @@ private function handleDiscardIndividualNodesFromWorkspace( DiscardIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); if (!$workspace->hasPublishableChanges()) { @@ -669,7 +687,7 @@ private function handleDiscardWorkspace( DiscardWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); if (!$workspace->hasPublishableChanges()) { @@ -696,6 +714,13 @@ private function discardWorkspace( Version $baseWorkspaceContentStreamVersion, ContentStreamId $newContentStream ): \Generator { + if (!$baseWorkspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($baseWorkspace->workspaceName); + } + if (!$workspace->isActive()) { + throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->workspaceName); + } + yield $this->forkContentStream( $newContentStream, $baseWorkspace->currentContentStreamId, @@ -731,7 +756,7 @@ private function handleChangeBaseWorkspace( ChangeBaseWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { - $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); $currentBaseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); $this->requireContentStreamToNotBeClosed($workspace->currentContentStreamId, $commandHandlingDependencies); @@ -744,7 +769,7 @@ private function handleChangeBaseWorkspace( throw WorkspaceContainsPublishableChanges::butWasNotSupposedToForBaseWorkspaceChange($workspace->workspaceName); } - $newBaseWorkspace = $this->requireWorkspace($command->baseWorkspaceName, $commandHandlingDependencies); + $newBaseWorkspace = $this->requireActiveWorkspace($command->baseWorkspaceName, $commandHandlingDependencies); $this->requireNonCircularRelationBetweenWorkspaces($workspace, $newBaseWorkspace, $commandHandlingDependencies); $newBaseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($newBaseWorkspace, $commandHandlingDependencies); @@ -779,29 +804,109 @@ private function handleDeleteWorkspace( CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId); + + if ($workspace->isActive()) { + $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId); + + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(), + Events::with( + new ContentStreamWasRemoved( + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::fromVersion($contentStreamVersion) + ); + } yield new EventsToPublish( - ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(), + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), Events::with( - new ContentStreamWasRemoved( - $workspace->currentContentStreamId, - ), + new WorkspaceWasRemoved( + $command->workspaceName, + ) ), - ExpectedVersion::fromVersion($contentStreamVersion) + ExpectedVersion::ANY() + ); + } + + /** + * @throws WorkspaceDoesNotExist + * @throws ContentStreamAlreadyExists + */ + private function handleActivateWorkspace( + ActivateWorkspace $command, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + $workspace = $commandHandlingDependencies->findWorkspaceByName($command->workspaceName); + if (is_null($workspace)) { + throw WorkspaceDoesNotExist::butWasSupposedTo($command->workspaceName); + } + if ($workspace->isActive()) { + throw WorkspaceIsActivated::butWasSupposedToBeDeactivated($command->workspaceName); + } + $baseWorkspace = $this->requireActiveWorkspace($workspace->baseWorkspaceName, $commandHandlingDependencies); + $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); + + $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies); + + yield $this->forkContentStream( + $command->newContentStreamId, + $baseWorkspace->currentContentStreamId, + $contentStreamVersion, + sprintf('Activate workspace %s based on %s', $workspace->workspaceName->value, $baseWorkspace->workspaceName->value) ); yield new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), Events::with( - new WorkspaceWasRemoved( + new WorkspaceWasActivated( $command->workspaceName, + $command->newContentStreamId, ) ), ExpectedVersion::ANY() ); } + /** + * @throws WorkspaceDoesNotExist + * @throws WorkspaceContainsPublishableChanges + */ + private function handleDeactivateWorkspace( + DeactivateWorkspace $command, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies); + //$contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId); + + if ($workspace->hasPublishableChanges()) { + throw WorkspaceContainsPublishableChanges::butWasNotSupposedToForDeactivating($workspace->workspaceName); + } + $this->requireWorkspaceToNotBeBaseForAnyOtherWorkspace($command->workspaceName, $commandHandlingDependencies); + + // TODO: Discuss order of deletion/deactivation + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasDeactivated($command->workspaceName) + ), + ExpectedVersion::ANY() + ); + + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(), + Events::with( + new ContentStreamWasRemoved( + $workspace->currentContentStreamId, + ), + ), + // content stream is unused by now and deletion is idempotent. There is no possible gain from a version + // check at this point. + ExpectedVersion::ANY() + ); + } + private function forkNewContentStreamAndApplyEvents( ContentStreamId $newContentStreamId, ContentStreamId $sourceContentStreamId, @@ -848,6 +953,11 @@ private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, Comman ), 1715341085); } + /** + * @param Workspace&object{ currentContentStreamId: ContentStreamId } $workspace + * @param CommandHandlingDependencies $commandHandlingDependencies + * @return Version + */ private function requireOpenContentStreamAndVersion(Workspace $workspace, CommandHandlingDependencies $commandHandlingDependencies): Version { if ($commandHandlingDependencies->isContentStreamClosed($workspace->currentContentStreamId)) { diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php index 0d7df9ec549..b41e7776c9d 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php @@ -13,6 +13,11 @@ */ final class WorkspaceContainsPublishableChanges extends \RuntimeException { + public static function butWasNotSupposedToForDeactivating(WorkspaceName $workspaceName): self + { + throw new self(sprintf('The workspace %s needs to be empty before being deactivated.', $workspaceName->value), 1765979146); + } + public static function butWasNotSupposedToForBaseWorkspaceChange(WorkspaceName $workspaceName): self { throw new self(sprintf('The workspace %s needs to be empty before switching the base workspace.', $workspaceName->value), 1681455989); diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php new file mode 100644 index 00000000000..6dcb677f011 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php @@ -0,0 +1,40 @@ + $workspaceName->value, + $dependingWorkspaceNames + ) + ), + $workspaceName, + ), 1766070671); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php new file mode 100644 index 00000000000..d6d3a22bace --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php @@ -0,0 +1,31 @@ +value + ), 1766069245); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php new file mode 100644 index 00000000000..e724ef87dc5 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php @@ -0,0 +1,31 @@ +value + ), 1765977861); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php index 5a9a9985e2e..e128e3d3438 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php @@ -24,19 +24,26 @@ /** * @param WorkspaceName $workspaceName Workspace identifier, unique within one Content Repository instance * @param WorkspaceName|null $baseWorkspaceName Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace - * @param ContentStreamId $currentContentStreamId The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace + * @param ContentStreamId|null $currentContentStreamId The Content Stream this workspace currently points to if it is active – usually it is set to a new, empty content stream after publishing/rebasing the workspace * @param WorkspaceStatus $status The current status of this workspace */ private function __construct( public WorkspaceName $workspaceName, public ?WorkspaceName $baseWorkspaceName, - public ContentStreamId $currentContentStreamId, + // TODO: ist allowing null here a problem? (strict phpstan?) + public ?ContentStreamId $currentContentStreamId, public WorkspaceStatus $status, private bool $hasPublishableChanges ) { if ($this->isRootWorkspace() && $this->hasPublishableChanges) { throw new \InvalidArgumentException('Root workspaces cannot have changes', 1730371566); } + if ($this->currentContentStreamId === null && $this->isActive()) { + throw new \InvalidArgumentException('Active workspaces must have a non null content stream ID', 1730371566); + } + if ($this->isRootWorkspace() && !$this->isActive()) { + throw new \InvalidArgumentException('Root Workspaces cannot be deactivated', 1768571925); + } } /** @@ -45,7 +52,7 @@ private function __construct( public static function create( WorkspaceName $workspaceName, ?WorkspaceName $baseWorkspaceName, - ContentStreamId $currentContentStreamId, + ?ContentStreamId $currentContentStreamId, WorkspaceStatus $status, bool $hasPublishableChanges ): self { @@ -60,6 +67,18 @@ public function hasPublishableChanges(): bool return $this->hasPublishableChanges; } + /** + * Indicates if the workspace is active. + * + * @phpstan-assert-if-true ContentStreamId $this->currentContentStreamId + * @phpstan-assert-if-true WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED $this->status + * @phpstan-assert-if-false WorkspaceName $this->baseWorkspaceName + */ + public function isActive(): bool + { + return $this->status !== WorkspaceStatus::DEACTIVATED; + } + /** * @phpstan-assert-if-false WorkspaceName $this->baseWorkspaceName */ diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php index 5ef187bb1db..cce28b7c775 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php @@ -47,6 +47,13 @@ enum WorkspaceStatus: string implements \JsonSerializable */ case OUTDATED = 'OUTDATED'; + /** + * A non-base workspace can be deactivated on purpose. A deactivated workspace has no current content stream. + * Before a deactivated workspace can be used it has to be activated again, which will result in its content stream + * to become a new fork of its base workspace content stream. + */ + case DEACTIVATED = 'DEACTIVATED'; + public function equals(self $other): bool { return $this->value === $other->value; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index fad57676f18..939c7b6de31 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -44,6 +44,8 @@ use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; @@ -312,6 +314,24 @@ public function theCommandRebaseWorkspaceIsExecutedWithPayload(TableNode $payloa $this->handleCommand(RebaseWorkspace::class, $commandArguments); } + /** + * @When the command DeactivateWorkspace is executed with payload: + */ + public function theCommandDeactivateWorkspaceIsExecutedWithPayload(TableNode $payloadTable): void + { + $commandArguments = $this->readPayloadTable($payloadTable); + $this->handleCommand(DeactivateWorkspace::class, $commandArguments); + } + + /** + * @When the command ActivateWorkspace is executed with payload: + */ + public function theCommandActivateWorkspaceIsExecutedWithPayload(TableNode $payloadTable): void + { + $commandArguments = $this->readPayloadTable($payloadTable); + $this->handleCommand(ActivateWorkspace::class, $commandArguments); + } + /** * @When the command :shortCommandName is executed with payload and exceptions are caught: */ @@ -477,6 +497,8 @@ protected static function resolveShortCommandName(string $shortCommandName): str CreateRootWorkspace::class, CreateWorkspace::class, DeleteWorkspace::class, + ActivateWorkspace::class, + DeactivateWorkspace::class, DisableNodeAggregate::class, DiscardIndividualNodesFromWorkspace::class, DiscardWorkspace::class, diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index c9b6a0eeec5..94efdac7b24 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,6 +14,11 @@ namespace Neos\Neos\Command; +use DateInterval; +use DateInvalidOperationException; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; @@ -26,6 +31,10 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; +use Neos\Flow\Security\Account; +use Neos\Flow\Utility\Now; +use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -56,6 +65,9 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected Now $now; + /** * Publish changes of a workspace * @@ -467,7 +479,7 @@ public function listCommand(string $contentRepository = 'default'): void $workspaceMetadata->title->value, $workspaceMetadata->description->value, $workspace->status->value, - $workspace->currentContentStreamId->value, + $workspace->currentContentStreamId?->value, ]; } $this->output->outputTable($tableRows, $headerRow); @@ -504,7 +516,7 @@ public function showCommand(string $workspace, string $contentRepository = 'defa $this->outputFormatted('Title: %s', [$workspaceMetadata->title->value]); $this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]); $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); - $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); + $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId?->value]); $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); $this->outputLine(); @@ -524,6 +536,129 @@ public function showCommand(string $workspace, string $contentRepository = 'defa ]); } + /** + * Deactivate a single workspace by name. + * + * A workspace can only be deactivated if it has no pending changes and no other workspace depends on it. + * + * @param string $workspace Name of the workspace to deactivate. + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @throws StopCommandException + * @throws AccessDenied + */ + public function deactivateCommand(string $workspace, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaceName = WorkspaceName::fromString($workspace); + $workspaceInstance = $contentRepositoryInstance->findWorkspaceByName($workspaceName); + if ($workspaceInstance === null) { + $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]); + $this->quit(); + } + if ($workspaceInstance->hasPublishableChanges()) { + $this->outputLine('Workspace "%s" cannot be deactivated, it has publishable changes.', [$workspaceName->value]); + $this->quit(); + } + $dependentWorkspaces = $contentRepositoryInstance->findWorkspaces() + ->filter(fn(Workspace $workspace) => $workspace->baseWorkspaceName === $workspaceName) + ->map(fn (Workspace $workspace) => $workspace->workspaceName->value); + if (count($dependentWorkspaces) > 0) { + $this->outputLine('Workspace "%s" cannot be deactivated, the following workspaces depend on it: "%s".', [ + $workspaceName->value, + implode('", "', $dependentWorkspaces) + ]); + $this->quit(); + } + + $contentRepositoryInstance->handle(DeactivateWorkspace::create($workspaceName)); + } + + /** + * Deactivate all stale personal workspaces. + * + * A personal workspace is considered stale if it has no pending changes, no other workspace uses it as a base + * workspace and the owner of the workspace did not log in for the time specified. + * + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @param string $dateInterval The time interval a user had to be inactive for its workspaces to be considered stale. (Default: '7 days') + * @throws AccessDenied + */ + public function deactivateStaleCommand(string $contentRepository = 'default', string $dateInterval = '7 days'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $interval = DateInterval::createFromDateString($dateInterval); + if ($interval === false) { + $this->outputLine('Invalid date interval "%s".', [$dateInterval]); + return; + } + + try { + $cutoffTime = $this->now->sub($interval); + // DateInvalidOperationException is introduced in php 8.3, therefore phpstan can't find it and complains + // about the next line in php 8.2 + } catch (\DateInvalidOperationException) { // @phpstan-ignore class.notFound,catch.neverThrown + $this->outputLine('Invalid date interval "%s".', [$interval]); + return; + } + + $workspaces = $contentRepositoryInstance->findWorkspaces(); + $baseWorkspaces = $this->splObjectStoreFromIterable($workspaces->map(fn($workspace) => $workspace->baseWorkspaceName)); + + $probablyStaleWorkspaceNames = $this->splObjectStoreFromIterable( + $workspaces + ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && + !$baseWorkspaces->contains($workspace->workspaceName)) + ->map(fn($workspace) => $workspace->workspaceName) + ); + $inactiveUserIds = $this->splObjectStoreFromIterable($this->userService->findUserIdsNotLoggedInBefore($cutoffTime)); + + $personalWorkspaces = $this->workspaceService->getPersonalWorkspaces($contentRepositoryId); + $workspacesToDeactivate = []; + foreach ($personalWorkspaces as $userId => $personalWorkspace) { + /** @var UserId $userId */ + if ( + $probablyStaleWorkspaceNames->contains($personalWorkspace) && + $inactiveUserIds->contains($userId) + ) { + $workspacesToDeactivate[] = $personalWorkspace; + } + } + + foreach ($workspacesToDeactivate as $workspace) { + $contentRepositoryInstance->handle(DeactivateWorkspace::create($workspace)); + } + } + /** + * Activate a single - previously deactivated - workspace by name. + * + * @param string $workspace Name of the workspace to deactivate. + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @throws StopCommandException + * @throws AccessDenied + */ + public function activateCommand(string $workspace, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaceName = WorkspaceName::fromString($workspace); + $workspaceInstance = $contentRepositoryInstance->findWorkspaceByName($workspaceName); + if ($workspaceInstance === null) { + $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]); + $this->quit(); + } + if ($workspaceInstance->isActive()) { + $this->outputLine('Workspace "%s" is already active.', [$workspaceName->value]); + $this->quit(); + } + + $contentRepositoryInstance->handle(ActivateWorkspace::create($workspaceName)); + } + // ----------------------- private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject @@ -540,4 +675,21 @@ private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType } return $roleSubject; } + + /** + * @template TObject of object + * @param iterable $iterable + * @return \SplObjectStorage + */ + private function splObjectStoreFromIterable(iterable $iterable): \SplObjectStorage + { + /** @var \SplObjectStorage $result */ + $result = new \SplObjectStorage(); + foreach ($iterable as $value) { + if ($value !== null) { + $result->attach($value); + } + } + return $result; + } } diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php index df4ead8c822..a0b2d717a36 100644 --- a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php +++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php @@ -385,6 +385,30 @@ classification = :personalWorkspaceClassification } } + /** + * @return \Traversable + */ + public function findAllPersonalWorkspaceNamesByContentRepositoryId(ContentRepositoryId $contentRepositoryId): \Traversable + { + $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAllAssociative($query, [ + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, + 'contentRepositoryId' => $contentRepositoryId->value, + ]); + foreach ($rows as $row) { + yield UserId::fromString($row['owner_user_id']) => WorkspaceName::fromString($row['workspace_name']); + } + } + /** * @param \Closure(): void $fn * @return void diff --git a/Neos.Neos/Classes/Domain/Service/SiteExportService.php b/Neos.Neos/Classes/Domain/Service/SiteExportService.php index ba305374607..6332b6665a1 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteExportService.php +++ b/Neos.Neos/Classes/Domain/Service/SiteExportService.php @@ -59,6 +59,10 @@ public function exportToPath(ContentRepositoryId $contentRepositoryId, string $p if ($liveWorkspace === null) { throw new \RuntimeException('Failed to find live workspace', 1716652280); } + if (!$liveWorkspace->isActive()) { + //TODO: should be impossible + throw new \RuntimeException('Live workspace was deactivated', 1768577266); + } $processors = Processors::fromArray([ 'Exporting events' => $this->contentRepositoryRegistry->buildService( diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index 9c6f9e52348..63e1c1d6467 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -14,6 +14,7 @@ namespace Neos\Neos\Domain\Service; +use DateTimeInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -775,6 +776,30 @@ public function getAllRoles(User $user): array return $roles; } + /** + * @param DateTimeInterface $dateTime + * @return \Traversable + */ + public function findUserIdsNotLoggedInBefore(DateTimeInterface $dateTime): \Traversable + { + /** @var User $user */ + foreach ($this->getUsers() as $user) { + $accounts = $user->getAccounts(); + $loggedIn = false; + foreach ($accounts as $account) { + $lastSuccessfulAuthenticationDate = $account->getLastSuccessfulAuthenticationDate(); + if ($lastSuccessfulAuthenticationDate != null && $lastSuccessfulAuthenticationDate > $dateTime) { + $loggedIn = true; + break; + } + } + + if (!$loggedIn) { + yield $user->getId(); + } + } + } + /** * @param User $user * @param bool $keepCurrentSession diff --git a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php index eb5280b56ed..0e08d13c617 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php @@ -371,13 +371,17 @@ private function resolveNodeIdsToPublishOrDiscard( private function pendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): Changes { $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); - return $contentRepository->projectionState(ChangeFinder::class)->findByContentStreamId($crWorkspace->currentContentStreamId); + return $crWorkspace->isActive() + ? $contentRepository->projectionState(ChangeFinder::class)->findByContentStreamId($crWorkspace->currentContentStreamId) + : Changes::fromArray([]); } private function countPendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): int { $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); - return $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId); + return $crWorkspace->isActive() + ? $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId) + : 0; } private function isChangePublishableWithinAncestorScope( diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index e9f7f92b2f4..28361b5ec47 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -15,6 +15,8 @@ namespace Neos\Neos\Domain\Service; use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -217,6 +219,44 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con ); } + public function isWorkspaceActive(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): bool + { + return $this->requireWorkspace($contentRepositoryId, $workspaceName)->isActive(); + } + + public function activatePersonalWorkspaceForUserIfDeactivated(ContentRepositoryId $contentRepositoryId, User $user): void + { + $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId()); + if ($existingWorkspaceName === null) { + throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $user->getId()->value), 1766049866); + } + + $workspaceIsActive = $this->isWorkspaceActive($contentRepositoryId, $existingWorkspaceName); + if ($workspaceIsActive) { + return; + } + + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle(ActivateWorkspace::create($existingWorkspaceName)); + } + + // TODO: required? If not remove + public function deactivatePersonalWorkspaceForUserIfActive(ContentRepositoryId $contentRepositoryId, User $user): void + { + $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId()); + if ($existingWorkspaceName === null) { + throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $user->getId()->value), 1766049866); + } + + $workspaceIsActive = $this->isWorkspaceActive($contentRepositoryId, $existingWorkspaceName); + if (!$workspaceIsActive) { + return; + } + + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle(DeactivateWorkspace::create($existingWorkspaceName)); + } + /** * Assign a workspace role to the given user/user group * @@ -301,6 +341,14 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } + /** + * @return \Traversable + */ + public function getPersonalWorkspaces(ContentRepositoryId $contentRepository): \Traversable + { + return $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepository); + } + // ------------------ /** diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 70fe40b4adf..95860320548 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -466,10 +466,9 @@ public function deleteAction(WorkspaceName $workspaceName): void $nodesCount = 0; try { - $nodesCount = $contentRepository->projectionState(ChangeFinder::class) - ->countByContentStreamId( - $workspace->currentContentStreamId - ); + $nodesCount = $workspace->currentContentStreamId === null ? 0 : + $contentRepository->projectionState(ChangeFinder::class) + ->countByContentStreamId($workspace->currentContentStreamId); } catch (\Exception $exception) { $message = $this->getModuleLabel( 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes', @@ -996,7 +995,7 @@ protected function renderContentChanges( ContentRepository $contentRepository, ): ContentChangeItems { $currentWorkspace = $contentRepository->findWorkspaces()->find( - fn (Workspace $potentialWorkspace) => $potentialWorkspace->currentContentStreamId->equals($contentStreamIdOfOriginalNode) + fn (Workspace $potentialWorkspace) => $potentialWorkspace->currentContentStreamId?->equals($contentStreamIdOfOriginalNode) ?? false ); $originalNode = null; if ($currentWorkspace !== null) { @@ -1327,6 +1326,10 @@ protected function getWorkspaceListItems( } protected function getChangesFromWorkspace(Workspace $selectedWorkspace,ContentRepository $contentRepository ): Changes{ + if (!$selectedWorkspace->isActive()) { + // since there is no content stream associated to them, inactive workspaces cannot contain changes + return Changes::fromArray([]); + } return $contentRepository->projectionState(ChangeFinder::class) ->findByContentStreamId( $selectedWorkspace->currentContentStreamId