From 25c3043b82bc9c5b98f0102bb4b51b56ffac5b5b Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 8 Aug 2026 21:51:07 -0400 Subject: [PATCH] feat: listen to user life cycle events Signed-off-by: Sebastian Krupinski --- lib/Listeners/UserEventListener.php | 58 ++++++++++ lib/Module.php | 10 ++ lib/Providers/Personal/PersonalService.php | 6 +- lib/Store/Personal/Store.php | 27 ++++- tests/php/Unit/UserEventListenerTest.php | 118 +++++++++++++++++++++ 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 lib/Listeners/UserEventListener.php create mode 100644 tests/php/Unit/UserEventListenerTest.php diff --git a/lib/Listeners/UserEventListener.php b/lib/Listeners/UserEventListener.php new file mode 100644 index 0000000..387ba76 --- /dev/null +++ b/lib/Listeners/UserEventListener.php @@ -0,0 +1,58 @@ +tenantIdentifier(); + $userId = $event->userIdentifier(); + + // A replayed lifecycle event must not create another default collection. + if ($this->store->collectionList($tenantId, $userId) !== []) { + return; + } + + $collection = $this->store->collectionFresh()->setEnabled(true); + $collection->getProperties() + ->setLabel(self::PERSONAL_ADDRESS_BOOK_LABEL) + ->setVisibility(true); + $this->store->collectionCreate($tenantId, $userId, $collection); + } + + public function onUserDeleting(UserDeletingEvent $event): void + { + $service = $this->service->initialize($event->tenantIdentifier(), $event->userIdentifier()); + + foreach ($service->collectionList(null) as $collection) { + $identifier = $collection->identifier(); + if ($identifier === null) { + continue; + } + + $service->collectionDelete(new CollectionIdentifier( + $collection->provider(), + (string) $collection->service(), + (string) $identifier, + ), true); + } + } + +} diff --git a/lib/Module.php b/lib/Module.php index e62c1e4..5b64d18 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -3,9 +3,14 @@ namespace KTXM\ProviderLocalPeople; use KTXC\Resource\ProviderManager; +use KTXC\User\Event\UserCreatedEvent; +use KTXC\User\Event\UserDeletingEvent; +use KTXF\Event\DeliveryMode; +use KTXF\Event\EventListenerRegistrarInterface; use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleInstanceAbstract; use KTXF\Resource\Provider\ProviderInterface; +use KTXM\ProviderLocalPeople\Listeners\UserEventListener; use KTXM\ProviderLocalPeople\Providers\Provider; /** @@ -15,6 +20,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface { public function __construct( private readonly ProviderManager $providerManager, + private readonly EventListenerRegistrarInterface $events, ) {} public function handle(): string @@ -55,6 +61,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface public function boot(): void { + // Register listeners + $this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred); + $this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred); + // Register providers $this->providerManager->register(ProviderInterface::TYPE_PEOPLE, 'default', Provider::class); } diff --git a/lib/Providers/Personal/PersonalService.php b/lib/Providers/Personal/PersonalService.php index c36439c..698ecc9 100644 --- a/lib/Providers/Personal/PersonalService.php +++ b/lib/Providers/Personal/PersonalService.php @@ -279,7 +279,11 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI throw new InvalidParameterException("Invalid: Collection identifier '$id' does not exist or does not belong to user '{$this->serviceUserId}'"); } // destroy collection in store - $this->store->collectionDestroyById($this->serviceTenantId, $this->serviceUserId, $id); + $collection = $this->store->collectionFetch($this->serviceTenantId, $this->serviceUserId, $id); + if ($collection === null) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' could not be retrieved"); + } + $this->store->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $collection); unset($this->serviceCollectionCache[$id]); return true; } diff --git a/lib/Store/Personal/Store.php b/lib/Store/Personal/Store.php index ef353b5..e6f93bc 100644 --- a/lib/Store/Personal/Store.php +++ b/lib/Store/Personal/Store.php @@ -183,6 +183,7 @@ class Store { return $list; } + /** * confirm if collections exist in data store * @@ -306,7 +307,12 @@ class Store { if ($identifier === null) { return $entity; } - return $this->collectionDestroyById($tenantId, $userId, (string) $identifier) ? $entity : $entity; + + $this->entityDestroyByCollectionId($tenantId, $userId, [(string) $identifier]); + $this->chronicleExpungeByCollectionId($tenantId, [(string) $identifier]); + $this->collectionDestroyById($tenantId, $userId, (string) $identifier); + + return $entity; } /** @@ -591,6 +597,21 @@ class Store { return false; } + /** + * Delete entities belonging to specific collections from the data store. + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $identifiers collection identifiers + */ + private function entityDestroyByCollectionId(string $tenantId, string $userId, array $identifiers): void { + $this->_store->selectCollection($this->_EntityTable)->deleteMany([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => ['$in' => $identifiers], + ]); + } + /** * chronicle a operation to an entity to the data store * @@ -758,10 +779,12 @@ class Store { * @since Release 1.0.0 * * @param array $identifiers collection of identifiers + * @param string $tenantId tenant identifier */ - private function chronicleExpungeByCollectionId(array $identifiers): void { + private function chronicleExpungeByCollectionId(string $tenantId, array $identifiers): void { // Delete chronicle entries for the specified collection identifiers $this->_store->selectCollection($this->_ChronicleTable)->deleteMany([ + 'tid' => $tenantId, 'cid' => ['$in' => $identifiers] ]); } diff --git a/tests/php/Unit/UserEventListenerTest.php b/tests/php/Unit/UserEventListenerTest.php new file mode 100644 index 0000000..a1b3af4 --- /dev/null +++ b/tests/php/Unit/UserEventListenerTest.php @@ -0,0 +1,118 @@ +createMock(ProviderManager::class); + $providers->expects(self::once()) + ->method('register') + ->with(ProviderInterface::TYPE_PEOPLE, 'default', Provider::class); + $events = new EventListenerRegistry(); + + (new Module($providers, $events))->boot(); + + $definitions = $events->definitions(); + self::assertCount(2, $definitions); + self::assertSame(UserCreatedEvent::class, $definitions[0]->event); + self::assertSame(UserEventListener::class, $definitions[0]->service); + self::assertSame('onUserCreated', $definitions[0]->method); + self::assertSame(DeliveryMode::Deferred, $definitions[0]->delivery); + self::assertSame(UserDeletingEvent::class, $definitions[1]->event); + self::assertSame(UserEventListener::class, $definitions[1]->service); + self::assertSame('onUserDeleting', $definitions[1]->method); + self::assertSame(DeliveryMode::Deferred, $definitions[1]->delivery); + } + + #[Test] + public function createsExactlyOnePersonalAddressBook(): void + { + $freshCollection = new CollectionResource(); + $store = $this->createMock(Store::class); + $store->expects(self::exactly(2)) + ->method('collectionList') + ->with('tenant-a', 'user-a') + ->willReturnOnConsecutiveCalls([], [new CollectionResource()]); + $store->expects(self::once()) + ->method('collectionFresh') + ->willReturn($freshCollection); + $store->expects(self::once()) + ->method('collectionCreate') + ->with( + 'tenant-a', + 'user-a', + self::callback(static fn(CollectionResource $collection): bool => + $collection->getProperties()->getLabel() === 'Personal Address Book' + && $collection->getProperties()->getVisibility() === true + && $collection->getEnabled()), + ) + ->willReturn(new CollectionResource()); + $listener = new UserEventListener($store, $this->createStub(PersonalService::class)); + $event = $this->userCreatedEvent(); + + $listener->onUserCreated($event); + $listener->onUserCreated($event); + + self::assertTrue($freshCollection->getEnabled()); + } + + #[Test] + public function deletesPersonalAddressBooks(): void + { + $collection = (new CollectionResource())->fromStore(['cid' => 'address-book-a']); + $service = $this->createMock(PersonalService::class); + $service->expects(self::once()) + ->method('initialize') + ->with('tenant-a', 'user-a') + ->willReturnSelf(); + $service->expects(self::once()) + ->method('collectionList') + ->with(null) + ->willReturn([$collection]); + $service->expects(self::once()) + ->method('collectionDelete') + ->with( + self::callback(static fn(CollectionIdentifier $target): bool => + $target->collection() === 'address-book-a'), + true, + ) + ->willReturn(true); + + (new UserEventListener( + $this->createStub(Store::class), + $service, + ))->onUserDeleting(UserDeletingEvent::fromUser([ + 'uid' => 'user-a', + 'identity' => 'person@example.test', + ], 'tenant-a')); + } + + private function userCreatedEvent(): UserCreatedEvent + { + return UserCreatedEvent::fromUser([ + 'uid' => 'user-a', + 'identity' => 'person@example.test', + ], 'tenant-a'); + } +}