From e21c98119e0c5d049ca587c89c64770a03cd5242 Mon Sep 17 00:00:00 2001 From: eli Date: Sun, 27 Sep 2026 12:18:01 +0800 Subject: [PATCH] feat: emit ApprovalStateChangedEvent on approval state changes The new OCA\Approval\Events\ApprovalStateChangedEvent is dispatched when an approval is requested, approved or rejected, right before the corresponding system tags are (un)assigned. Next to the acting user it carries the user who requested the approval. That information is otherwise lost on approve/reject, because the pending activity row is replaced by the approver's one before the tag change is triggered. This enables other apps, e.g. workflow engines like workflow_script, to react to approval state changes on behalf of the requester, see https://github.com/nextcloud/workflow_script/issues/363 Signed-off-by: eli --- lib/Events/ApprovalStateChangedEvent.php | 67 ++++++++++++++++++++++ lib/Service/ApprovalService.php | 29 ++++++++++ tests/unit/Service/ApprovalServiceTest.php | 39 ++++++++++++- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 lib/Events/ApprovalStateChangedEvent.php diff --git a/lib/Events/ApprovalStateChangedEvent.php b/lib/Events/ApprovalStateChangedEvent.php new file mode 100644 index 00000000..0027e08b --- /dev/null +++ b/lib/Events/ApprovalStateChangedEvent.php @@ -0,0 +1,67 @@ +fileId; + } + + public function getRuleId(): int { + return $this->ruleId; + } + + /** + * One of Application::STATE_PENDING, Application::STATE_APPROVED or Application::STATE_REJECTED + */ + public function getNewState(): int { + return $this->newState; + } + + /** + * User who performed the action: + * the requester on request, the approver on approve/reject + */ + public function getActorUserId(): ?string { + return $this->actorUserId; + } + + /** + * User who requested the approval, if known + */ + public function getRequesterUserId(): ?string { + return $this->requesterUserId; + } +} diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php index 48db25b9..3d9408c6 100644 --- a/lib/Service/ApprovalService.php +++ b/lib/Service/ApprovalService.php @@ -10,8 +10,10 @@ use DateTime; use OCA\Approval\Activity\ActivityManager; use OCA\Approval\AppInfo\Application; +use OCA\Approval\Events\ApprovalStateChangedEvent; use OCA\Approval\Exceptions\OutdatedEtagException; use OCP\App\IAppManager; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\ICachedMountFileInfo; use OCP\Files\Config\IUserMountCache; use OCP\Files\FileInfo; @@ -50,6 +52,7 @@ public function __construct( private LoggerInterface $logger, private ?string $userId, private IUserMountCache $userMountCache, + private IEventDispatcher $eventDispatcher, ) { } @@ -381,8 +384,17 @@ public function approve(int $fileId, ?string $userId, string $etag, string $mess try { if ($this->tagObjectMapper->haveTag((string)$fileId, 'files', $rule['tagPending']) && $this->userIsAuthorizedByRule($userId, $rule, 'approvers')) { + // get the requester before storeAction() replaces the pending activity row + $pendingAction = $this->ruleService->getLastAction($fileId, $ruleId, Application::STATE_PENDING); + // store activity in our tables $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_APPROVED, $message); + + // dispatch before the tags change so listeners reacting to the tag events have the context + $this->eventDispatcher->dispatchTyped( + new ApprovalStateChangedEvent($fileId, $ruleId, Application::STATE_APPROVED, $userId, $pendingAction['userId'] ?? null) + ); + // Change tags $this->tagObjectMapper->assignTags((string)$fileId, 'files', $rule['tagApproved']); $this->tagObjectMapper->unassignTags((string)$fileId, 'files', $rule['tagPending']); @@ -424,9 +436,17 @@ public function reject(int $fileId, ?string $userId, string $etag, string $messa try { if ($this->tagObjectMapper->haveTag((string)$fileId, 'files', $rule['tagPending']) && $this->userIsAuthorizedByRule($userId, $rule, 'approvers')) { + // get the requester before storeAction() replaces the pending activity row + $pendingAction = $this->ruleService->getLastAction($fileId, $ruleId, Application::STATE_PENDING); + // store activity in our tables $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_REJECTED, $message); + // dispatch before the tags change so listeners reacting to the tag events have the context + $this->eventDispatcher->dispatchTyped( + new ApprovalStateChangedEvent($fileId, $ruleId, Application::STATE_REJECTED, $userId, $pendingAction['userId'] ?? null) + ); + // Change tags $this->tagObjectMapper->assignTags((string)$fileId, 'files', $rule['tagRejected']); $this->tagObjectMapper->unassignTags((string)$fileId, 'files', $rule['tagPending']); @@ -485,6 +505,11 @@ public function request(int $fileId, int $ruleId, ?string $requesterUserId, bool // store activity in our tables $this->ruleService->storeAction($fileId, $ruleId, $requesterUserId, Application::STATE_PENDING); + // dispatch before the tag is assigned so listeners reacting to the tag event have the context + $this->eventDispatcher->dispatchTyped( + new ApprovalStateChangedEvent($fileId, $ruleId, Application::STATE_PENDING, $requesterUserId, $requesterUserId) + ); + $this->tagObjectMapper->assignTags((string)$fileId, 'files', $rule['tagPending']); // still produce an activity entry for the user who requests @@ -530,6 +555,10 @@ public function requestViaTagAssignment(int $fileId, int $ruleId, string $reques // store activity in our tables $this->ruleService->storeAction($fileId, $ruleId, $requesterUserId, Application::STATE_PENDING); + $this->eventDispatcher->dispatchTyped( + new ApprovalStateChangedEvent($fileId, $ruleId, Application::STATE_PENDING, $requesterUserId, $requesterUserId) + ); + // still produce an activity entry for the user who requests $this->activityManager->triggerEvent( ActivityManager::APPROVAL_OBJECT_NODE, $fileId, diff --git a/tests/unit/Service/ApprovalServiceTest.php b/tests/unit/Service/ApprovalServiceTest.php index 40da6fea..194cd43a 100644 --- a/tests/unit/Service/ApprovalServiceTest.php +++ b/tests/unit/Service/ApprovalServiceTest.php @@ -10,7 +10,9 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Approval\Activity\ActivityManager; use OCA\Approval\AppInfo\Application; +use OCA\Approval\Events\ApprovalStateChangedEvent; use OCP\App\IAppManager; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\IUserMountCache; use OCP\Files\IRootFolder; use OCP\ICacheFactory; @@ -97,7 +99,8 @@ protected function setUp(): void { $c->get(IL10N::class), $c->get(LoggerInterface::class), 'user1', - $c->get(IUserMountCache::class) + $c->get(IUserMountCache::class), + $c->get(IEventDispatcher::class) ); // add some tags @@ -427,4 +430,38 @@ public function testRequestWithCreateSharesWhenUserCannotShareReturnsError(): vo $this->ruleService->deleteRule($ruleId); } + + public function testApprovalStateChangedEventIsEmitted(): void { + $app = new Application(); + $eventDispatcher = $app->getContainer()->get(IEventDispatcher::class); + + $emittedEvents = []; + $eventDispatcher->addListener(ApprovalStateChangedEvent::class, function (ApprovalStateChangedEvent $event) use (&$emittedEvents) { + $emittedEvents[] = $event; + }); + + // create a file + $uf1 = $this->root->getUserFolder('user1'); + $file = $uf1->newFile('eventFile.txt', 'content'); + + // requesting approval emits an event carrying the requester + $this->approvalService->request($file->getId(), $this->idRule1, 'user1', false); + $this->assertCount(1, $emittedEvents); + $event = $emittedEvents[0]; + $this->assertSame($file->getId(), $event->getFileId()); + $this->assertEquals($this->idRule1, $event->getRuleId()); + $this->assertSame(Application::STATE_PENDING, $event->getNewState()); + $this->assertSame('user1', $event->getActorUserId()); + $this->assertSame('user1', $event->getRequesterUserId()); + + // approving emits an event still carrying the requester + $this->approvalService->approve($file->getId(), 'user1', $file->getEtag()); + $this->assertCount(2, $emittedEvents); + $event = $emittedEvents[1]; + $this->assertSame($file->getId(), $event->getFileId()); + $this->assertEquals($this->idRule1, $event->getRuleId()); + $this->assertSame(Application::STATE_APPROVED, $event->getNewState()); + $this->assertSame('user1', $event->getActorUserId()); + $this->assertSame('user1', $event->getRequesterUserId()); + } }