From e62cb5f374675a1212417adef043c8e6f4ff9975 Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Thu, 12 Mar 2026 14:36:46 +0100 Subject: [PATCH 01/19] FEATURE: Command to delete stale workspaces --- .../Command/WorkspaceCommandController.php | 79 +++++++++++++++++++ .../WorkspaceMetadataAndRoleRepository.php | 24 ++++++ .../Classes/Domain/Service/UserService.php | 25 ++++++ .../Domain/Service/WorkspaceService.php | 8 ++ .../Features/Bootstrap/UserServiceTrait.php | 44 +++++++++++ .../Bootstrap/WorkspaceServiceTrait.php | 29 +++++++ .../ContentRepository/UserService.feature | 33 ++++++++ .../WorkspaceService.feature | 12 +++ phpstan-baseline.neon | 8 ++ 9 files changed, 262 insertions(+) create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index c9b6a0eeec5..b4db3dfe87c 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,7 +14,11 @@ namespace Neos\Neos\Command; +use DateInterval; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; +use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; @@ -26,6 +30,8 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; +use Neos\Flow\Utility\Now; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -37,6 +43,7 @@ use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\Domain\Service\WorkspaceService; +use SplObjectStorage; /** * The Workspace Command Controller @@ -56,6 +63,9 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected Now $now; + /** * Publish changes of a workspace * @@ -524,6 +534,57 @@ public function showCommand(string $workspace, string $contentRepository = 'defa ]); } + /** + * Removes 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 + * @throws \DateInvalidOperationException + */ + public function removeStaleCommand(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('Unable to parse date interval "%s".', [$dateInterval]); + $this->quit(); + } + + $workspaces = $contentRepositoryInstance->findWorkspaces(); + $baseWorkspaceNames = $this->splObjectStoreFromIterable($workspaces->map(fn($workspace) => $workspace->baseWorkspaceName)); + + $probablyStaleWorkspaceNames = $this->splObjectStoreFromIterable( + $workspaces + ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && + !$baseWorkspaceNames->contains($workspace->workspaceName)) + ->map(fn($workspace) => $workspace->workspaceName) + ); + + $inactiveUserIds = $this->splObjectStoreFromIterable($this->userService->findUserIdsNotLoggedInAfter($this->now->sub($interval))); + + $personalWorkspaces = $this->workspaceService->getPersonalWorkspaceNames($contentRepositoryId); + $workspacesToRemove = []; + foreach ($personalWorkspaces as $userId => $personalWorkspace) { + /** @var UserId $userId */ + if ( + $probablyStaleWorkspaceNames->contains($personalWorkspace) && + $inactiveUserIds->contains($userId) + ) { + $workspacesToRemove[] = $personalWorkspace; + } + } + + foreach ($workspacesToRemove as $workspace) { + $contentRepositoryInstance->handle(DeleteWorkspace::create($workspace)); + } + } + // ----------------------- private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject @@ -540,4 +601,22 @@ private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType } return $roleSubject; } + + /** + * @template T of object + * + * @param iterable $iterable + * @return SplObjectStorage + */ + private function splObjectStoreFromIterable(iterable $iterable): SplObjectStorage + { + /** @var SplObjectStorage $result */ + $result = new SplObjectStorage(); + foreach ($iterable as $workspace) { + if ($workspace !== null) { + $result->attach($workspace); + } + } + 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/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index 9c6f9e52348..0132b17bb59 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 findUserIdsNotLoggedInAfter(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/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index e9f7f92b2f4..5ad35427f84 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -301,6 +301,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 getPersonalWorkspaceNames(ContentRepositoryId $contentRepository): \Traversable + { + return $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepository); + } + // ------------------ /** diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 6c62dd6065b..7cc514f36af 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -20,6 +20,9 @@ use Neos\Neos\Domain\Service\UserService; use Neos\Party\Domain\Model\PersonName; use Neos\Utility\ObjectAccess; +use function PHPUnit\Framework\assertEmpty; +use function PHPUnit\Framework\assertSameSize; +use function PHPUnit\Framework\assertTrue; /** * Step implementations for UserService related tests inside Neos.Neos @@ -72,6 +75,47 @@ public function theFollowingNeosUsersExist(TableNode $usersTable): void } } + /** + * @When Neos user :username last logged in :days days ago + */ + public function neosUserLastLoggedInDaysAgo(string $username, int $days): void + { + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + $lastLoginDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); + $user->getAccounts()->map(function($account) use ($lastLoginDate) { + $refLastSuccessfulAuthenticationDate = new ReflectionProperty(\Neos\Flow\Security\Account::class, 'lastSuccessfulAuthenticationDate'); + $refLastSuccessfulAuthenticationDate->setAccessible(true); + $refLastSuccessfulAuthenticationDate->setValue($account, $lastLoginDate); + }); + $userService->updateUser($user); + } + + /** + * @Then the following users did not log in within :days days: + */ + public function theFollowingUsersDidNotLogInWithinXDays(int $days, TableNode $usersTable): void + { + /** + * @var array $expected + */ + $expected = []; + foreach ($usersTable->getHash() as $userData) { + $expected[$userData['Id']] = true; + } + + $userService = $this->getObject(UserService::class); + $cutoffDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); + $actual = iterator_to_array($userService->findUserIdsNotLoggedInAfter($cutoffDate)); + + foreach ($actual as $userId) { + $userIdString = $userId->value; + assertTrue(isset($expected[$userIdString]), "User \"$userIdString\" did no login within $days days, but was expected to."); + unset($expected[$userIdString]); + } + assertEmpty($expected, "The following users were missing from user not logged in within $days days: " . join(', ', $expected)); + } + private function createUser(string $username, ?string $firstName = null, ?string $lastName = null, ?array $roleIdentifiers = null, ?string $id = null): void { $userService = $this->getObject(UserService::class); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 25c910675c9..3c8c8152379 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -14,6 +14,7 @@ use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -359,6 +360,34 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, Assert::assertFalse($permissions->manage); } + /** + * @Then the following personal workspaces exist in content repository :contentRepositoryId: + */ + public function theFollowingPersonalWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesAndUsernames): void + { + $expectedWorkspaces = []; + foreach ($workspacesAndUsernames->getColumnsHash() as $workspaceAndUsername) { + $expectedWorkspaces[$workspaceAndUsername["Userid"]] = $workspaceAndUsername["WorkspaceName"]; + } + + $actualWorkspaces = $this->getObject(WorkspaceService::class)->getPersonalWorkspaceNames( + ContentRepositoryId::fromString($contentRepositoryId) + ); + + $count = 0; + foreach ($actualWorkspaces as $userId => $workspace) { + $count++; + $userIdString = $userId->value; + $workspaceString = $workspace->value; + Assert::assertTrue(array_key_exists($userIdString, $expectedWorkspaces), "Found unexpected workspace $workspaceString for UserId $userIdString"); + + $workspaceForId = $expectedWorkspaces[$userIdString]; + Assert::assertEquals($workspaceForId, $workspace->value, "workspace for userId $workspaceForId is $workspaceString but was expected to be $workspaceForId"); + } + $expectedCount = count($expectedWorkspaces); + Assert::assertEquals($expectedCount, $count, "Expected $expectedCount personal workspaces, but found $count"); + } + private function userIdForUsername(string $username): UserId { $user = $this->getObject(UserService::class)->getUser($username); diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature new file mode 100644 index 00000000000..01430b5f924 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature @@ -0,0 +1,33 @@ +@flowEntities +Feature: Neos UserService related features + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | editor | Edward | Editor | Neos.Neos:Editor | + + Scenario: List user accounts not logged in for some time + When Neos user "jane.doe" last logged in 9 days ago + And Neos user "john.doe" last logged in 6 days ago + And Neos user "editor" last logged in 5 days ago + Then the following users did not log in within 7 days: + | Id | + | janedoe | + And the following users did not log in within 6 days: + | Id | + | janedoe | + | johndoe | + And the following users did not log in within 3 days: + | Id | + | janedoe | + | johndoe | + | editor | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index c91c1a3f3b6..f18e60d795e 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -285,6 +285,18 @@ Feature: Neos WorkspaceService related features When the role MANAGER is assigned to workspace "some-root-workspace" for user "editor" And the Neos user "editor" should have the permissions "read,write,manage" for workspace "some-root-workspace" + Scenario: List personal workspaces + When the root workspace "some-root-workspace" is created + Then the following personal workspaces exist in content repository "default": + | WorkspaceName | Userid | + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And the personal workspace "johndoe-user-workspace" is created with the target workspace "some-root-workspace" for user "john.doe" + Then the following personal workspaces exist in content repository "default": + | WorkspaceName | Userid | + | janedoe-user-workspace | janedoe | + | johndoe-user-workspace | johndoe | + Scenario: Permissions for workspace without metadata Given a root workspace "some-root-workspace" exists without metadata When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index aab9e3e26d4..2092b550621 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -214,3 +214,11 @@ parameters: message: "#^Return type \\(void\\) of method Neos\\\\Neos\\\\ResourceManagement\\\\NodeTypesStreamWrapper\\:\\:removeDirectory\\(\\) should be compatible with return type \\(bool\\) of method Neos\\\\Flow\\\\ResourceManagement\\\\Streams\\\\StreamWrapperInterface\\:\\:removeDirectory\\(\\)$#" count: 1 path: Neos.Neos/Classes/ResourceManagement/NodeTypesStreamWrapper.php + + - + # DateInvalidOperationException was introduced in PHP 8.3 - on PHP 8.2 the class does not exist and PHPStan reports throws.notThrowable + message: "#^PHPDoc tag @throws with type DateInvalidOperationException(\\|[^ ]+)? is not subtype of Throwable$#" + count: 1 + path: Neos.Neos/Classes/Command/WorkspaceCommandController.php + # on PHP 8.3 and newer this ignore is unmatched and therefore would be reported as such + reportUnmatched: false From 6a2f20e2fb9ff90d314b9cf28664c1db4086570a Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Thu, 2 Apr 2026 14:02:12 +0200 Subject: [PATCH 02/19] TASK: Move stale workspace detection logic to WorkspaceService --- .../Command/WorkspaceCommandController.php | 51 +------------------ .../Domain/Service/WorkspaceService.php | 39 +++++++++++++- .../Bootstrap/WorkspaceServiceTrait.php | 9 ++-- .../WorkspaceService.feature | 42 +++++++++++---- 4 files changed, 76 insertions(+), 65 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index b4db3dfe87c..bcb299a5854 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -16,7 +16,6 @@ use DateInterval; use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; -use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; @@ -30,8 +29,6 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; -use Neos\Flow\Utility\Now; -use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -43,7 +40,6 @@ use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\Domain\Service\WorkspaceService; -use SplObjectStorage; /** * The Workspace Command Controller @@ -63,9 +59,6 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected WorkspaceService $workspaceService; - #[Flow\Inject] - protected Now $now; - /** * Publish changes of a workspace * @@ -556,31 +549,9 @@ public function removeStaleCommand(string $contentRepository = 'default', string $this->quit(); } - $workspaces = $contentRepositoryInstance->findWorkspaces(); - $baseWorkspaceNames = $this->splObjectStoreFromIterable($workspaces->map(fn($workspace) => $workspace->baseWorkspaceName)); - - $probablyStaleWorkspaceNames = $this->splObjectStoreFromIterable( - $workspaces - ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && - !$baseWorkspaceNames->contains($workspace->workspaceName)) - ->map(fn($workspace) => $workspace->workspaceName) - ); - - $inactiveUserIds = $this->splObjectStoreFromIterable($this->userService->findUserIdsNotLoggedInAfter($this->now->sub($interval))); - - $personalWorkspaces = $this->workspaceService->getPersonalWorkspaceNames($contentRepositoryId); - $workspacesToRemove = []; - foreach ($personalWorkspaces as $userId => $personalWorkspace) { - /** @var UserId $userId */ - if ( - $probablyStaleWorkspaceNames->contains($personalWorkspace) && - $inactiveUserIds->contains($userId) - ) { - $workspacesToRemove[] = $personalWorkspace; - } - } + $staleWorkspaces = $this->workspaceService->getStaleWorkspaceNames($contentRepositoryId, $interval); - foreach ($workspacesToRemove as $workspace) { + foreach ($staleWorkspaces as $workspace) { $contentRepositoryInstance->handle(DeleteWorkspace::create($workspace)); } } @@ -601,22 +572,4 @@ private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType } return $roleSubject; } - - /** - * @template T of object - * - * @param iterable $iterable - * @return SplObjectStorage - */ - private function splObjectStoreFromIterable(iterable $iterable): SplObjectStorage - { - /** @var SplObjectStorage $result */ - $result = new SplObjectStorage(); - foreach ($iterable as $workspace) { - if ($workspace !== null) { - $result->attach($workspace); - } - } - return $result; - } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 5ad35427f84..a2b9cb0b5c4 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,6 +14,7 @@ namespace Neos\Neos\Domain\Service; +use DateInterval; use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; @@ -27,6 +28,7 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; +use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -40,6 +42,7 @@ use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Neos\Domain\SubtreeTagging\SoftRemoval\SoftRemovalGarbageCollector; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; +use SplObjectStorage; /** * Central authority to interact with Content Repository Workspaces within Neos @@ -56,6 +59,7 @@ public function __construct( private ContentRepositoryAuthorizationService $authorizationService, private SecurityContext $securityContext, private SoftRemovalGarbageCollector $softRemovalGarbageCollector, + private Now $now, ) { } @@ -302,11 +306,42 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, } /** + * @param ContentRepositoryId $contentRepositoryId + * @param DateInterval $interval * @return \Traversable + * @throws \DateInvalidOperationException */ - public function getPersonalWorkspaceNames(ContentRepositoryId $contentRepository): \Traversable + public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, DateInterval $interval): \Traversable { - return $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaces = $contentRepositoryInstance->findWorkspaces(); + $baseWorkspaceNames = array_flip(iterator_to_array($workspaces + ->filter(fn($workspace) => $workspace->baseWorkspaceName !== null) + ->map(fn($workspace) => $workspace->baseWorkspaceName->value) + )); + + $probablyStaleWorkspaceNames = array_flip(iterator_to_array( + $workspaces + ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && + !array_key_exists($workspace->workspaceName->value, $baseWorkspaceNames)) + ->map(fn($workspace) => $workspace->workspaceName->value) + )); + + $inactiveUserIds = array_flip(array_map( + fn($userId) => $userId->value, + iterator_to_array($this->userService->findUserIdsNotLoggedInAfter($this->now->sub($interval))) + )); + + $personalWorkspaces = $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepositoryId); + foreach ($personalWorkspaces as $userId => $personalWorkspace) { + if ( + array_key_exists($personalWorkspace->value, $probablyStaleWorkspaceNames) && + array_key_exists($userId->value, $inactiveUserIds) + ) { + yield $userId => $personalWorkspace; + } + } } // ------------------ diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 3c8c8152379..8a47a09dace 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -361,17 +361,18 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, } /** - * @Then the following personal workspaces exist in content repository :contentRepositoryId: + * @Then the following stale workspaces exist in content repository :contentRepositoryId: */ - public function theFollowingPersonalWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesAndUsernames): void + public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesAndUsernames): void { $expectedWorkspaces = []; foreach ($workspacesAndUsernames->getColumnsHash() as $workspaceAndUsername) { $expectedWorkspaces[$workspaceAndUsername["Userid"]] = $workspaceAndUsername["WorkspaceName"]; } - $actualWorkspaces = $this->getObject(WorkspaceService::class)->getPersonalWorkspaceNames( - ContentRepositoryId::fromString($contentRepositoryId) + $actualWorkspaces = $this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( + ContentRepositoryId::fromString($contentRepositoryId), + new DateInterval('P7D'), ); $count = 0; diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index f18e60d795e..07b5a2d0ea9 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -5,7 +5,7 @@ Feature: Neos WorkspaceService related features Given using no content dimensions And using the following node types: """yaml - 'Neos.ContentRepository:Root': {} + 'Neos.ContentRepository.Testing:Node': {} """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -285,21 +285,43 @@ Feature: Neos WorkspaceService related features When the role MANAGER is assigned to workspace "some-root-workspace" for user "editor" And the Neos user "editor" should have the permissions "read,write,manage" for workspace "some-root-workspace" - Scenario: List personal workspaces + Scenario: Permissions for workspace without metadata + Given a root workspace "some-root-workspace" exists without metadata + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe" + Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" + And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace" + And the Neos user "editor" should have no permissions for workspace "some-root-workspace" + + Scenario: Personal Workspaces without change and without user login within 7 days are stale When the root workspace "some-root-workspace" is created - Then the following personal workspaces exist in content repository "default": + Then the following stale workspaces exist in content repository "default": | WorkspaceName | Userid | When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And Neos user "jane.doe" last logged in 9 days ago And the personal workspace "johndoe-user-workspace" is created with the target workspace "some-root-workspace" for user "john.doe" - Then the following personal workspaces exist in content repository "default": + And Neos user "john.doe" last logged in 8 days ago + And the personal workspace "editor-user-workspace" is created with the target workspace "some-root-workspace" for user "editor" + And Neos user "editor" last logged in 5 days ago + Then the following stale workspaces exist in content repository "default": | WorkspaceName | Userid | | janedoe-user-workspace | janedoe | | johndoe-user-workspace | johndoe | - Scenario: Permissions for workspace without metadata - Given a root workspace "some-root-workspace" exists without metadata - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe" - Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" - And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace" - And the Neos user "editor" should have no permissions for workspace "some-root-workspace" + Scenario: Workspaces with changes are not stale + Given the root workspace "some-root-workspace" is created + And I am in workspace "some-root-workspace" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And I am in workspace "janedoe-user-workspace" + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:Node | {} | + And Neos user "jane.doe" last logged in 9 days ago + + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | Userid | From 09c92e26faf5c4db10c3120843fb1d635524eb83 Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Thu, 2 Apr 2026 14:23:25 +0200 Subject: [PATCH 03/19] TASK: Refactor checking if a workspace has workspaces depending on them --- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index a2b9cb0b5c4..192ea186ff5 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -42,7 +42,6 @@ use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Neos\Domain\SubtreeTagging\SoftRemoval\SoftRemovalGarbageCollector; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; -use SplObjectStorage; /** * Central authority to interact with Content Repository Workspaces within Neos @@ -316,15 +315,10 @@ public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspaces = $contentRepositoryInstance->findWorkspaces(); - $baseWorkspaceNames = array_flip(iterator_to_array($workspaces - ->filter(fn($workspace) => $workspace->baseWorkspaceName !== null) - ->map(fn($workspace) => $workspace->baseWorkspaceName->value) - )); - $probablyStaleWorkspaceNames = array_flip(iterator_to_array( $workspaces ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && - !array_key_exists($workspace->workspaceName->value, $baseWorkspaceNames)) + $workspaces->getDependantWorkspacesRecursively($workspace->workspaceName)->isEmpty()) ->map(fn($workspace) => $workspace->workspaceName->value) )); From 97836c2e383862f08b39979e12fa1ae6b389863f Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:38:35 +0200 Subject: [PATCH 04/19] TASK: Remove `@throws` annotations which have no effect on a Flow CLI command either way ... these exceptions should not be thrown in a cli controller either way as the output would just confuse users, i hope the `false` check is enough:) ... and cause oddities in php-stan baseline. --- Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 -- phpstan-baseline.neon | 8 -------- 2 files changed, 10 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index bcb299a5854..293e2a1616e 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -535,8 +535,6 @@ public function showCommand(string $workspace, string $contentRepository = 'defa * * @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 - * @throws \DateInvalidOperationException */ public function removeStaleCommand(string $contentRepository = 'default', string $dateInterval = '7 days'): void { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2092b550621..aab9e3e26d4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -214,11 +214,3 @@ parameters: message: "#^Return type \\(void\\) of method Neos\\\\Neos\\\\ResourceManagement\\\\NodeTypesStreamWrapper\\:\\:removeDirectory\\(\\) should be compatible with return type \\(bool\\) of method Neos\\\\Flow\\\\ResourceManagement\\\\Streams\\\\StreamWrapperInterface\\:\\:removeDirectory\\(\\)$#" count: 1 path: Neos.Neos/Classes/ResourceManagement/NodeTypesStreamWrapper.php - - - - # DateInvalidOperationException was introduced in PHP 8.3 - on PHP 8.2 the class does not exist and PHPStan reports throws.notThrowable - message: "#^PHPDoc tag @throws with type DateInvalidOperationException(\\|[^ ]+)? is not subtype of Throwable$#" - count: 1 - path: Neos.Neos/Classes/Command/WorkspaceCommandController.php - # on PHP 8.3 and newer this ignore is unmatched and therefore would be reported as such - reportUnmatched: false From 3db6be53450e7c83d69c5e471c56ddc03f485f86 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:05:53 +0200 Subject: [PATCH 05/19] TASK: Keep time calculation logic in command controller to have WorkspaceService more pure and testable --- .../Command/WorkspaceCommandController.php | 17 +++++++++++++---- .../Classes/Domain/Service/UserService.php | 5 ++--- .../Classes/Domain/Service/WorkspaceService.php | 10 ++-------- .../Bootstrap/WorkspaceServiceTrait.php | 3 ++- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 293e2a1616e..4b2c7f28c8b 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,8 +14,6 @@ namespace Neos\Neos\Command; -use DateInterval; -use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; @@ -29,6 +27,7 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; +use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -59,6 +58,9 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected Now $now; + /** * Publish changes of a workspace * @@ -541,13 +543,20 @@ public function removeStaleCommand(string $contentRepository = 'default', string $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); - $interval = DateInterval::createFromDateString($dateInterval); + $interval = \DateInterval::createFromDateString($dateInterval); if ($interval === false) { $this->outputLine('Unable to parse date interval "%s".', [$dateInterval]); $this->quit(); } - $staleWorkspaces = $this->workspaceService->getStaleWorkspaceNames($contentRepositoryId, $interval); + $ownerUserNotLoggedInAfter = $this->now->sub($interval); + if ($this->now <= $ownerUserNotLoggedInAfter) { + $this->outputLine('Date interval must not be negative or zero "%s".', [$dateInterval]); + $this->quit(); + } + + $staleWorkspaces = iterator_to_array($this->workspaceService->getStaleWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter), false); + $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaces), $ownerUserNotLoggedInAfter->format('Y-m-d')]); foreach ($staleWorkspaces as $workspace) { $contentRepositoryInstance->handle(DeleteWorkspace::create($workspace)); diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index 0132b17bb59..0e74fe43f71 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -14,7 +14,6 @@ namespace Neos\Neos\Domain\Service; -use DateTimeInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -777,10 +776,10 @@ public function getAllRoles(User $user): array } /** - * @param DateTimeInterface $dateTime + * @param \DateTimeInterface $dateTime * @return \Traversable */ - public function findUserIdsNotLoggedInAfter(DateTimeInterface $dateTime): \Traversable + public function findUserIdsNotLoggedInAfter(\DateTimeInterface $dateTime): \Traversable { /** @var User $user */ foreach ($this->getUsers() as $user) { diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 192ea186ff5..dc5e67e0bca 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,7 +14,6 @@ namespace Neos\Neos\Domain\Service; -use DateInterval; use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; @@ -28,7 +27,6 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -58,7 +56,6 @@ public function __construct( private ContentRepositoryAuthorizationService $authorizationService, private SecurityContext $securityContext, private SoftRemovalGarbageCollector $softRemovalGarbageCollector, - private Now $now, ) { } @@ -305,12 +302,9 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, } /** - * @param ContentRepositoryId $contentRepositoryId - * @param DateInterval $interval * @return \Traversable - * @throws \DateInvalidOperationException */ - public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, DateInterval $interval): \Traversable + public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): \Traversable { $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -324,7 +318,7 @@ public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, $inactiveUserIds = array_flip(array_map( fn($userId) => $userId->value, - iterator_to_array($this->userService->findUserIdsNotLoggedInAfter($this->now->sub($interval))) + iterator_to_array($this->userService->findUserIdsNotLoggedInAfter($ownerUserNotLoggedInAfter)) )); $personalWorkspaces = $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepositoryId); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 8a47a09dace..69a9e2a9a10 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -19,6 +19,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRBehavioralTestsSubjectProvider; +use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -372,7 +373,7 @@ public function theFollowingStaleWorkspacesExistInContentRepository(string $cont $actualWorkspaces = $this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( ContentRepositoryId::fromString($contentRepositoryId), - new DateInterval('P7D'), + $this->getObject(Now::class)->sub(new DateInterval('P7D')) ); $count = 0; From 5e6100acb6d7213873424179e5ce13c666c2f50b Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:10:19 +0200 Subject: [PATCH 06/19] TASK: Use static `PHPUnit\Framework\Assert` as in other places --- .../Behavior/Features/Bootstrap/UserServiceTrait.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 7cc514f36af..b5693bb789d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -20,9 +20,7 @@ use Neos\Neos\Domain\Service\UserService; use Neos\Party\Domain\Model\PersonName; use Neos\Utility\ObjectAccess; -use function PHPUnit\Framework\assertEmpty; -use function PHPUnit\Framework\assertSameSize; -use function PHPUnit\Framework\assertTrue; +use PHPUnit\Framework\Assert; /** * Step implementations for UserService related tests inside Neos.Neos @@ -110,10 +108,10 @@ public function theFollowingUsersDidNotLogInWithinXDays(int $days, TableNode $us foreach ($actual as $userId) { $userIdString = $userId->value; - assertTrue(isset($expected[$userIdString]), "User \"$userIdString\" did no login within $days days, but was expected to."); + Assert::assertTrue(isset($expected[$userIdString]), "User \"$userIdString\" did no login within $days days, but was expected to."); unset($expected[$userIdString]); } - assertEmpty($expected, "The following users were missing from user not logged in within $days days: " . join(', ', $expected)); + Assert::assertEmpty($expected, "The following users were missing from user not logged in within $days days: " . join(', ', $expected)); } private function createUser(string $username, ?string $firstName = null, ?string $lastName = null, ?array $roleIdentifiers = null, ?string $id = null): void From 865bea703c4c617db4ae330e8c8fe52997f7a7bd Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:22:28 +0200 Subject: [PATCH 07/19] TASK: Simplify api of `getStaleWorkspaceNames()` to not include unused UserId and simplify behat step impl --- .../Domain/Service/WorkspaceService.php | 4 ++-- .../Bootstrap/WorkspaceServiceTrait.php | 24 ++++--------------- .../WorkspaceService.feature | 10 ++++---- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index dc5e67e0bca..4aa2ad104ed 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -302,7 +302,7 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, } /** - * @return \Traversable + * @return \Traversable */ public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): \Traversable { @@ -327,7 +327,7 @@ public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, array_key_exists($personalWorkspace->value, $probablyStaleWorkspaceNames) && array_key_exists($userId->value, $inactiveUserIds) ) { - yield $userId => $personalWorkspace; + yield $personalWorkspace; } } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 69a9e2a9a10..d2bd5b55aa5 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -364,30 +364,14 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, /** * @Then the following stale workspaces exist in content repository :contentRepositoryId: */ - public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesAndUsernames): void + public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesNames): void { - $expectedWorkspaces = []; - foreach ($workspacesAndUsernames->getColumnsHash() as $workspaceAndUsername) { - $expectedWorkspaces[$workspaceAndUsername["Userid"]] = $workspaceAndUsername["WorkspaceName"]; - } - - $actualWorkspaces = $this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( + $actualWorkspaces = iterator_to_array($this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( ContentRepositoryId::fromString($contentRepositoryId), $this->getObject(Now::class)->sub(new DateInterval('P7D')) - ); + ), false); - $count = 0; - foreach ($actualWorkspaces as $userId => $workspace) { - $count++; - $userIdString = $userId->value; - $workspaceString = $workspace->value; - Assert::assertTrue(array_key_exists($userIdString, $expectedWorkspaces), "Found unexpected workspace $workspaceString for UserId $userIdString"); - - $workspaceForId = $expectedWorkspaces[$userIdString]; - Assert::assertEquals($workspaceForId, $workspace->value, "workspace for userId $workspaceForId is $workspaceString but was expected to be $workspaceForId"); - } - $expectedCount = count($expectedWorkspaces); - Assert::assertEquals($expectedCount, $count, "Expected $expectedCount personal workspaces, but found $count"); + Assert::assertEquals(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), array_map(fn (WorkspaceName $workspaceName) => $workspaceName->value, $actualWorkspaces)); } private function userIdForUsername(string $username): UserId diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 07b5a2d0ea9..156ec2914ec 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -295,7 +295,7 @@ Feature: Neos WorkspaceService related features Scenario: Personal Workspaces without change and without user login within 7 days are stale When the root workspace "some-root-workspace" is created Then the following stale workspaces exist in content repository "default": - | WorkspaceName | Userid | + | WorkspaceName | When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" And Neos user "jane.doe" last logged in 9 days ago @@ -304,9 +304,9 @@ Feature: Neos WorkspaceService related features And the personal workspace "editor-user-workspace" is created with the target workspace "some-root-workspace" for user "editor" And Neos user "editor" last logged in 5 days ago Then the following stale workspaces exist in content repository "default": - | WorkspaceName | Userid | - | janedoe-user-workspace | janedoe | - | johndoe-user-workspace | johndoe | + | WorkspaceName | + | janedoe-user-workspace | + | johndoe-user-workspace | Scenario: Workspaces with changes are not stale Given the root workspace "some-root-workspace" is created @@ -324,4 +324,4 @@ Feature: Neos WorkspaceService related features And Neos user "jane.doe" last logged in 9 days ago Then the following stale workspaces exist in content repository "default": - | WorkspaceName | Userid | + | WorkspaceName | From adaa2bccbf4c4c569db211158d299b732a41474e Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:55:49 +0200 Subject: [PATCH 08/19] TASK: Introduce typed collections for `WorkspaceNames` and `UserIds` ... because they are now used as return values in classes marked public `@api` and it simplifies logic like checking if a name exists within that set. Also simplify theFollowingUsersDidNotLogInWithinXDays step to be less convoluted and prone to bugs. --- .../SharedModel/Workspace/WorkspaceNames.php | 50 +++++++++++++++ .../Workspace/WorkspaceNamesTest.php | 63 +++++++++++++++++++ Neos.Neos/Classes/Domain/Model/UserIds.php | 44 +++++++++++++ .../Classes/Domain/Service/UserService.php | 11 ++-- .../Domain/Service/WorkspaceService.php | 31 +++++---- .../Features/Bootstrap/UserServiceTrait.php | 17 +---- .../Bootstrap/WorkspaceServiceTrait.php | 6 +- .../WorkspaceService.feature | 1 + .../Tests/Unit/Domain/Model/UserIdsTest.php | 27 ++++++++ 9 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php create mode 100644 Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php create mode 100644 Neos.Neos/Classes/Domain/Model/UserIds.php create mode 100644 Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php new file mode 100644 index 00000000000..1f5fca326ac --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php @@ -0,0 +1,50 @@ + + * @api + */ +final readonly class WorkspaceNames implements \IteratorAggregate +{ + /** @param array $items */ + private function __construct( + private array $items + ) { + } + + public static function create( + WorkspaceName ...$items + ): self { + $indexed = []; + foreach ($items as $item) { + $indexed[$item->value] = $item; + } + return new self( + $indexed + ); + } + + public static function fromWorkspaces(Workspaces $workspaces): self + { + return WorkspaceNames::create(...$workspaces->map(fn (Workspace $workspace) => $workspace->workspaceName)); + } + + public function contain(WorkspaceName $workspaceName): bool + { + return array_key_exists($workspaceName->value, $this->items); + } + + /** @return list */ + public function toStringArray(): array + { + return array_keys($this->items); + } + + public function getIterator(): \Traversable + { + yield from array_values($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php new file mode 100644 index 00000000000..071a09ed79e --- /dev/null +++ b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php @@ -0,0 +1,63 @@ +contain($nameInstance)); + self::assertTrue($workspaceNames->contain(WorkspaceName::fromString('foo'))); + self::assertFalse($workspaceNames->contain(WorkspaceName::fromString('not-included'))); + } + + /** + * @test + */ + public function fromWorkspaces() + { + $workspaces = Workspaces::fromArray([ + self::workspace('root', null), + self::workspace('regular', 'root'), + self::workspace('other-root', null), + ]); + + self::assertEquals( + WorkspaceNames::create( + WorkspaceName::fromString('root'), + WorkspaceName::fromString('regular'), + WorkspaceName::fromString('other-root'), + ), + WorkspaceNames::fromWorkspaces($workspaces) + ); + } + + private static function workspace(string $name): Workspace + { + return Workspace::create( + WorkspaceName::fromString($name), + null, + ContentStreamId::create(), + WorkspaceStatus::UP_TO_DATE, + false + ); + } +} diff --git a/Neos.Neos/Classes/Domain/Model/UserIds.php b/Neos.Neos/Classes/Domain/Model/UserIds.php new file mode 100644 index 00000000000..3d22431ebbe --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/UserIds.php @@ -0,0 +1,44 @@ + + */ +final readonly class UserIds implements \IteratorAggregate +{ + /** @param array $items */ + private function __construct( + private array $items + ) { + } + + public static function create( + UserId ...$items + ): self { + $indexed = []; + foreach ($items as $item) { + $indexed[$item->value] = $item; + } + return new self( + $indexed + ); + } + + public function contain(UserId $userId): bool + { + return array_key_exists($userId->value, $this->items); + } + + /** @return list */ + public function toStringArray(): array + { + return array_keys($this->items); + } + + public function getIterator(): \Traversable + { + yield from array_values($this->items); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index 0e74fe43f71..4fe34de4fe0 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -39,6 +39,7 @@ use Neos\Neos\Domain\Exception; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; +use Neos\Neos\Domain\Model\UserIds; use Neos\Neos\Domain\Repository\UserRepository; use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Party\Domain\Model\AbstractParty; @@ -775,12 +776,9 @@ public function getAllRoles(User $user): array return $roles; } - /** - * @param \DateTimeInterface $dateTime - * @return \Traversable - */ - public function findUserIdsNotLoggedInAfter(\DateTimeInterface $dateTime): \Traversable + public function findUserIdsNotLoggedInAfter(\DateTimeInterface $dateTime): UserIds { + $userIds = []; /** @var User $user */ foreach ($this->getUsers() as $user) { $accounts = $user->getAccounts(); @@ -794,9 +792,10 @@ public function findUserIdsNotLoggedInAfter(\DateTimeInterface $dateTime): \Trav } if (!$loggedIn) { - yield $user->getId(); + $userIds[] = $user->getId(); } } + return UserIds::create(...$userIds); } /** diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 4aa2ad104ed..ecb497633fb 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -24,6 +24,7 @@ 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\WorkspaceNames; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; @@ -301,35 +302,31 @@ 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 getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): \Traversable + public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): WorkspaceNames { $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspaces = $contentRepositoryInstance->findWorkspaces(); - $probablyStaleWorkspaceNames = array_flip(iterator_to_array( - $workspaces - ->filter(fn($workspace) => !$workspace->hasPublishableChanges() && - $workspaces->getDependantWorkspacesRecursively($workspace->workspaceName)->isEmpty()) - ->map(fn($workspace) => $workspace->workspaceName->value) - )); + $probablyStaleWorkspaceNames = WorkspaceNames::fromWorkspaces( + $workspaces->filter( + fn($workspace) => !$workspace->hasPublishableChanges() && + $workspaces->getDependantWorkspacesRecursively($workspace->workspaceName)->isEmpty() + ) + ); - $inactiveUserIds = array_flip(array_map( - fn($userId) => $userId->value, - iterator_to_array($this->userService->findUserIdsNotLoggedInAfter($ownerUserNotLoggedInAfter)) - )); + $inactiveUserIds = $this->userService->findUserIdsNotLoggedInAfter($ownerUserNotLoggedInAfter); $personalWorkspaces = $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepositoryId); + $staleWorkspaceNames = []; foreach ($personalWorkspaces as $userId => $personalWorkspace) { if ( - array_key_exists($personalWorkspace->value, $probablyStaleWorkspaceNames) && - array_key_exists($userId->value, $inactiveUserIds) + $probablyStaleWorkspaceNames->contain($personalWorkspace) + && $inactiveUserIds->contain($userId) ) { - yield $personalWorkspace; + $staleWorkspaceNames[] = $personalWorkspace; } } + return WorkspaceNames::create(...$staleWorkspaceNames); } // ------------------ diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index b5693bb789d..765c2b62fa1 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -94,24 +94,11 @@ public function neosUserLastLoggedInDaysAgo(string $username, int $days): void */ public function theFollowingUsersDidNotLogInWithinXDays(int $days, TableNode $usersTable): void { - /** - * @var array $expected - */ - $expected = []; - foreach ($usersTable->getHash() as $userData) { - $expected[$userData['Id']] = true; - } - $userService = $this->getObject(UserService::class); $cutoffDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); - $actual = iterator_to_array($userService->findUserIdsNotLoggedInAfter($cutoffDate)); + $userIds = $userService->findUserIdsNotLoggedInAfter($cutoffDate); - foreach ($actual as $userId) { - $userIdString = $userId->value; - Assert::assertTrue(isset($expected[$userIdString]), "User \"$userIdString\" did no login within $days days, but was expected to."); - unset($expected[$userIdString]); - } - Assert::assertEmpty($expected, "The following users were missing from user not logged in within $days days: " . join(', ', $expected)); + Assert::assertEquals(array_column($usersTable->getColumnsHash(), 'Id'), $userIds->toStringArray()); } private function createUser(string $username, ?string $firstName = null, ?string $lastName = null, ?array $roleIdentifiers = null, ?string $id = null): void diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index d2bd5b55aa5..94c9f207c37 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -366,12 +366,12 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, */ public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesNames): void { - $actualWorkspaces = iterator_to_array($this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( + $actualWorkspaceNames = $this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( ContentRepositoryId::fromString($contentRepositoryId), $this->getObject(Now::class)->sub(new DateInterval('P7D')) - ), false); + ); - Assert::assertEquals(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), array_map(fn (WorkspaceName $workspaceName) => $workspaceName->value, $actualWorkspaces)); + Assert::assertEquals(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), $actualWorkspaceNames->toStringArray()); } private function userIdForUsername(string $username): UserId diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 156ec2914ec..7205a055b90 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -5,6 +5,7 @@ Feature: Neos WorkspaceService related features Given using no content dimensions And using the following node types: """yaml + 'Neos.ContentRepository:Root': {} 'Neos.ContentRepository.Testing:Node': {} """ And using identifier "default", I define a content repository diff --git a/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php b/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php new file mode 100644 index 00000000000..6cd0cf5790a --- /dev/null +++ b/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php @@ -0,0 +1,27 @@ +contain($nameInstance)); + self::assertTrue($workspaceNames->contain(UserId::fromString('foo'))); + self::assertFalse($workspaceNames->contain(UserId::fromString('not-included'))); + } +} From 8e7240b15fe691a08dec146dd2f2c4239e6ad6f3 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:59:54 +0200 Subject: [PATCH 09/19] TASK: Rename `getStaleWorkspaceNames` to `getStalePersonalWorkspaceNames` --- Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 +- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 2 +- .../Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 4b2c7f28c8b..8d8d8aa5716 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -555,7 +555,7 @@ public function removeStaleCommand(string $contentRepository = 'default', string $this->quit(); } - $staleWorkspaces = iterator_to_array($this->workspaceService->getStaleWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter), false); + $staleWorkspaces = iterator_to_array($this->workspaceService->getStalePersonalWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter), false); $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaces), $ownerUserNotLoggedInAfter->format('Y-m-d')]); foreach ($staleWorkspaces as $workspace) { diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index ecb497633fb..96dfdf4e1c4 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -302,7 +302,7 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } - public function getStaleWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): WorkspaceNames + public function getStalePersonalWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): WorkspaceNames { $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 94c9f207c37..286540ab1ae 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -366,7 +366,7 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, */ public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesNames): void { - $actualWorkspaceNames = $this->getObject(WorkspaceService::class)->getStaleWorkspaceNames( + $actualWorkspaceNames = $this->getObject(WorkspaceService::class)->getStalePersonalWorkspaceNames( ContentRepositoryId::fromString($contentRepositoryId), $this->getObject(Now::class)->sub(new DateInterval('P7D')) ); From e3509889d699908b618a5e21b728e7345ac7780a Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:05:27 +0200 Subject: [PATCH 10/19] TASK: countable WorkspaceNames --- .../Classes/SharedModel/Workspace/WorkspaceNames.php | 7 ++++++- Neos.Neos/Classes/Command/WorkspaceCommandController.php | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php index 1f5fca326ac..8cbeea69072 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php @@ -7,7 +7,7 @@ * @implements \IteratorAggregate * @api */ -final readonly class WorkspaceNames implements \IteratorAggregate +final readonly class WorkspaceNames implements \IteratorAggregate, \Countable { /** @param array $items */ private function __construct( @@ -47,4 +47,9 @@ public function getIterator(): \Traversable { yield from array_values($this->items); } + + public function count(): int + { + return count($this->items); + } } diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 8d8d8aa5716..32ea1d1e5e0 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -555,10 +555,10 @@ public function removeStaleCommand(string $contentRepository = 'default', string $this->quit(); } - $staleWorkspaces = iterator_to_array($this->workspaceService->getStalePersonalWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter), false); - $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaces), $ownerUserNotLoggedInAfter->format('Y-m-d')]); + $staleWorkspaceNames = $this->workspaceService->getStalePersonalWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter); + $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaceNames), $ownerUserNotLoggedInAfter->format('Y-m-d')]); - foreach ($staleWorkspaces as $workspace) { + foreach ($staleWorkspaceNames as $workspace) { $contentRepositoryInstance->handle(DeleteWorkspace::create($workspace)); } } From 28a95276ff159cf9d8cf6dd40e0c70292e1ebf99 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:21:09 +0200 Subject: [PATCH 11/19] TASK: Move feature from `ContentRepository` context to own `User` context --- .../Features/{ContentRepository => User}/UserService.feature | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Neos.Neos/Tests/Behavior/Features/{ContentRepository => User}/UserService.feature (100%) diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature b/Neos.Neos/Tests/Behavior/Features/User/UserService.feature similarity index 100% rename from Neos.Neos/Tests/Behavior/Features/ContentRepository/UserService.feature rename to Neos.Neos/Tests/Behavior/Features/User/UserService.feature From e0e8d44f4d18f755cfaca9a79605f36d8aafd29c Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:43:12 +0200 Subject: [PATCH 12/19] TASK: Ensure WorkspaceService fully encapsulates stale workspace deletion to also trigger the garbage collection at the end. Also its improving the API. --- .../Command/WorkspaceCommandController.php | 4 +--- .../Domain/Service/WorkspaceService.php | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 32ea1d1e5e0..fbbbba478fb 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -558,9 +558,7 @@ public function removeStaleCommand(string $contentRepository = 'default', string $staleWorkspaceNames = $this->workspaceService->getStalePersonalWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter); $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaceNames), $ownerUserNotLoggedInAfter->format('Y-m-d')]); - foreach ($staleWorkspaceNames as $workspace) { - $contentRepositoryInstance->handle(DeleteWorkspace::create($workspace)); - } + $this->workspaceService->deleteStalePersonalWorkspaces($contentRepositoryId, $staleWorkspaceNames); } // ----------------------- diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 96dfdf4e1c4..baf240fa993 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -263,6 +263,29 @@ public function deleteWorkspace(ContentRepositoryId $contentRepositoryId, Worksp $this->softRemovalGarbageCollector->run($contentRepositoryId); } + /** + * Cleans up stale user workspaces - in combination with {@see getStalePersonalWorkspaceNames}. + * Any metadata and roles attached to the personal workspaces are kept. + * To recreate the cores workspace {@see createPersonalWorkspaceForUserIfMissing} must be used. + */ + public function deleteStalePersonalWorkspaces(ContentRepositoryId $contentRepositoryId, WorkspaceNames $workspaceNames): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + + foreach ($workspaceNames as $workspaceName) { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + + // We keep the Neos workspace metadata and roles for the time the workspace is recreated. + $contentRepository->handle( + DeleteWorkspace::create( + $workspaceName + ) + ); + } + + $this->softRemovalGarbageCollector->run($contentRepositoryId); + } + /** * Get all role assignments for the specified workspace * From 64f9112a7e141e0c8175ed59aa7f282648194507 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:44:04 +0200 Subject: [PATCH 13/19] WIP: Add failing test that `createPersonalWorkspaceForUserIfMissing()` does not recreate the workspace as promised > The workspace "janedoe-user-workspace" does not exist in content repository "default" this is due to the check in 207 --- .../Features/Bootstrap/CRTestSuiteTrait.php | 8 +++++-- .../Bootstrap/WorkspaceServiceTrait.php | 12 ++++++++++ .../WorkspaceService.feature | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index b3d4aaf0cc4..4d63bc56c93 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -136,13 +136,17 @@ public function iExpectTheFollowingWorkspaces(TableNode $payloadTable): void { $actualComparableHash = []; $workspaces = $this->currentContentRepository->findWorkspaces(); + + // if not specified, ignore + $compareContentStream = in_array('content stream', $payloadTable->getRow(0), true); + foreach ($workspaces as $workspace) { $actualComparableHash[] = array_map(json_encode(...), [ 'name' => $workspace->workspaceName, 'base workspace' => $workspace->baseWorkspaceName, 'status' => $workspace->status, - 'content stream' => $workspace->currentContentStreamId, - 'publishable changes' => $workspace->hasPublishableChanges() + ...($compareContentStream ? ['content stream' => $workspace->currentContentStreamId] : []), + ...['publishable changes' => $workspace->hasPublishableChanges()] ]); } Assert::assertSame($payloadTable->getHash(), $actualComparableHash); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 286540ab1ae..734d0241260 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -374,6 +374,18 @@ public function theFollowingStaleWorkspacesExistInContentRepository(string $cont Assert::assertEquals(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), $actualWorkspaceNames->toStringArray()); } + /** + * @Then the stale workspaces are deleted in content repository :contentRepositoryId + */ + public function theStaleWorkspacesAreDeletedInContentRepository(string $contentRepositoryId): void + { + $workspacesNames = $this->getObject(WorkspaceService::class)->getStalePersonalWorkspaceNames( + ContentRepositoryId::fromString($contentRepositoryId), + $this->getObject(Now::class)->sub(new DateInterval('P7D')) + ); + $this->getObject(WorkspaceService::class)->deleteStalePersonalWorkspaces(ContentRepositoryId::fromString($contentRepositoryId), $workspacesNames); + } + private function userIdForUsername(string $username): UserId { $user = $this->getObject(UserService::class)->getUser($username); diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 7205a055b90..ed5f294134a 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -304,11 +304,34 @@ Feature: Neos WorkspaceService related features And Neos user "john.doe" last logged in 8 days ago And the personal workspace "editor-user-workspace" is created with the target workspace "some-root-workspace" for user "editor" And Neos user "editor" last logged in 5 days ago + + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "janedoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "johndoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + Then the following stale workspaces exist in content repository "default": | WorkspaceName | | janedoe-user-workspace | | johndoe-user-workspace | + And the stale workspaces are deleted in content repository "default" + Then I expect the workspace "janedoe-user-workspace" to not exist + Then I expect the workspace "johndoe-user-workspace" to not exist + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + + When a personal workspace for user "jane.doe" is created + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "janedoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + Scenario: Workspaces with changes are not stale Given the root workspace "some-root-workspace" is created And I am in workspace "some-root-workspace" From 321a7ef6311e390d2180012d66d7fdc1e5c674bc Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Tue, 7 Apr 2026 09:54:30 +0200 Subject: [PATCH 14/19] TASK: Do not force specific order in tests for stale workspaces and users not logged in within a given time --- .../Tests/Behavior/Features/Bootstrap/UserServiceTrait.php | 2 +- .../Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 765c2b62fa1..f48a026c817 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -98,7 +98,7 @@ public function theFollowingUsersDidNotLogInWithinXDays(int $days, TableNode $us $cutoffDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); $userIds = $userService->findUserIdsNotLoggedInAfter($cutoffDate); - Assert::assertEquals(array_column($usersTable->getColumnsHash(), 'Id'), $userIds->toStringArray()); + Assert::assertEqualsCanonicalizing(array_column($usersTable->getColumnsHash(), 'Id'), $userIds->toStringArray()); } private function createUser(string $username, ?string $firstName = null, ?string $lastName = null, ?array $roleIdentifiers = null, ?string $id = null): void diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 734d0241260..782df49f89d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -371,7 +371,7 @@ public function theFollowingStaleWorkspacesExistInContentRepository(string $cont $this->getObject(Now::class)->sub(new DateInterval('P7D')) ); - Assert::assertEquals(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), $actualWorkspaceNames->toStringArray()); + Assert::assertEqualsCanonicalizing(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), $actualWorkspaceNames->toStringArray()); } /** From 336be2f6b8aaeacefa869cd96e8450928880804c Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Wed, 8 Apr 2026 14:27:32 +0200 Subject: [PATCH 15/19] TASK: Allow any order for set of existing workspaces in tests --- .../Behavior/Features/Bootstrap/CRTestSuiteTrait.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index 4d63bc56c93..4407e570bd1 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -149,7 +149,11 @@ public function iExpectTheFollowingWorkspaces(TableNode $payloadTable): void ...['publishable changes' => $workspace->hasPublishableChanges()] ]); } - Assert::assertSame($payloadTable->getHash(), $actualComparableHash); + // ensure the check result does not depend on the workspace ordering + $expected = $payloadTable->getHash(); + usort($expected, fn($a, $b) => $a['name'] <=> $b['name']); + usort($actualComparableHash, fn($a, $b) => $a['name'] <=> $b['name']); + Assert::assertSame($expected, $actualComparableHash); } /** From 5e988aba5e7bf1a2a0398ca3fe48b58635f23aec Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Wed, 8 Apr 2026 14:30:22 +0200 Subject: [PATCH 16/19] TASK: Recreate personal Workspaces if Metadata still exists but the Workspace is missing (deleted due to being stale) The recreated Workspace is based on the live workspace even if it was based on another workspace before being deleted. --- .../Domain/Service/WorkspaceService.php | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index baf240fa993..970f05a681f 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -108,7 +108,8 @@ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentReposito if ($workspaceName === null) { throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); } - return $this->requireWorkspace($contentRepositoryId, $workspaceName); + + return $this->recreateStaleWorkspaceIfMissing($contentRepositoryId, $workspaceName); } /** @@ -153,14 +154,7 @@ public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId if ($existingUserWorkspace !== null) { throw new \RuntimeException(sprintf('Failed to create personal workspace "%s" for user with id "%s", because the workspace "%s" is already assigned to the user', $workspaceName->value, $ownerId->value, $existingUserWorkspace->value), 1733754904); } - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspaceName, - ContentStreamId::create() - ) - ); + $this->createPersonalWorkspaceWithoutMetadata($contentRepositoryId, $workspaceName, $baseWorkspaceName); $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::PERSONAL, $ownerId); } @@ -204,7 +198,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con { $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId()); if ($existingWorkspaceName !== null) { - $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); + $this->recreateStaleWorkspaceIfMissing($contentRepositoryId, $existingWorkspaceName); return; } $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); @@ -353,6 +347,30 @@ public function getStalePersonalWorkspaceNames(ContentRepositoryId $contentRepos } // ------------------ + private function recreateStaleWorkspaceIfMissing(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace + { + try { + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } catch (WorkspaceDoesNotExist $e) { + //TODO: how to restore the correct baseWorkspaceName? What if it does not exist anymore? Should it be restored at all? + $this->createPersonalWorkspaceWithoutMetadata($contentRepositoryId, $workspaceName, WorkspaceName::forLive()); + } + + // do not catch again, if it fails now something is definitely wrong + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } + + private function createPersonalWorkspaceWithoutMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $baseWorkspaceName): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + ContentStreamId::create() + ) + ); + } /** * @throws WorkspaceDoesNotExist if the workspace does not exist From be9a9d4f17bb7cfb9b66d84e590eb6a6cbe0b198 Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Wed, 8 Apr 2026 14:31:47 +0200 Subject: [PATCH 17/19] TASK: Run WorkspaceMetadata Cleanup after test rather than before to ensure the database state is clean after each test. --- .../Behavior/Features/Bootstrap/WorkspaceServiceTrait.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 782df49f89d..1eb14230c74 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -52,9 +52,13 @@ trait WorkspaceServiceTrait abstract private function getObject(string $className): object; /** - * @BeforeScenario + * Cleanup _after_ scenario. If cleaned up with `BeforeScenario` the workspaces from the last scenario remain in the + * database. The next test run will not clean them up before the first scenario since no content repositories are + * already set up at that moment. + * + * @AfterScenario */ - final public function pruneWorkspaceService(): void + final public function pruneWorkspaceService($param): void { foreach (static::$alreadySetUpContentRepositories as $contentRepositoryId) { $this->getObject(\Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository::class)->pruneWorkspaceMetadata($contentRepositoryId); From 70e390dbc7b98dd7190fd178cb46d9e66851a37b Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Wed, 8 Apr 2026 14:36:27 +0200 Subject: [PATCH 18/19] WIP: Add testcase to show stale workspace recreation works for "live" as base workspace for the recreated user workspace --- .../WorkspaceService.feature | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index ed5f294134a..a0556a29939 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -332,6 +332,33 @@ Feature: Neos WorkspaceService related features | "janedoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | | "some-root-workspace" | null | "UP_TO_DATE" | false | + Scenario: Personal Workspaces recreation works if the base workspace is "live" + When the root workspace "live" is created + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "live" for user "jane.doe" + And Neos user "jane.doe" last logged in 9 days ago + + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "janedoe-user-workspace" | "live" | "UP_TO_DATE" | false | + | "live" | null | "UP_TO_DATE" | false | + + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | + | janedoe-user-workspace | + + And the stale workspaces are deleted in content repository "default" + Then I expect the workspace "janedoe-user-workspace" to not exist + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "live" | null | "UP_TO_DATE" | false | + + When a personal workspace for user "jane.doe" is created + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "janedoe-user-workspace" | "live" | "UP_TO_DATE" | false | + | "live" | null | "UP_TO_DATE" | false | + Scenario: Workspaces with changes are not stale Given the root workspace "some-root-workspace" is created And I am in workspace "some-root-workspace" From 445a6eb0d31dbc922db870a86568ce512c4ad032 Mon Sep 17 00:00:00 2001 From: Andreas Sacher Date: Wed, 8 Apr 2026 14:39:32 +0200 Subject: [PATCH 19/19] Task: Fix linting errors --- .../Classes/SharedModel/Workspace/WorkspaceNames.php | 3 ++- Neos.Neos/Classes/Domain/Model/UserIds.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php index 8cbeea69072..00f62c39987 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php @@ -1,4 +1,5 @@ value] = $item; - } + } return new self( $indexed ); diff --git a/Neos.Neos/Classes/Domain/Model/UserIds.php b/Neos.Neos/Classes/Domain/Model/UserIds.php index 3d22431ebbe..0e659083c6c 100644 --- a/Neos.Neos/Classes/Domain/Model/UserIds.php +++ b/Neos.Neos/Classes/Domain/Model/UserIds.php @@ -1,4 +1,5 @@ value] = $item; - } + } return new self( $indexed );