Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.23% covered (warning)
85.23%
75 / 88
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignerRemovalService
85.23% covered (warning)
85.23%
75 / 88
60.00% covered (warning)
60.00%
6 / 10
43.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 persistRemoval
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
11
 getRemovalTargets
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 requireSigner
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 assertCanRemove
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 rejectRemoval
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 revertToDraftWhenEmpty
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 invalidateCaches
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
9.89
 notifyCancellation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\FileElementMapper;
13use OCA\Libresign\Db\FileMapper;
14use OCA\Libresign\Db\NotificationDeliveryMapper;
15use OCA\Libresign\Db\SignRequest as SignRequestEntity;
16use OCA\Libresign\Db\SignRequestMapper;
17use OCA\Libresign\Enum\FileStatus;
18use OCA\Libresign\Enum\SignRequestStatus;
19use OCA\Libresign\Events\SignRequestCanceledEvent;
20use OCA\Libresign\Exception\LibresignException;
21use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
22use OCA\Libresign\Service\SignerRemoval\RemovalChanges;
23use OCP\AppFramework\Db\DoesNotExistException;
24use OCP\AppFramework\Db\TTransactional;
25use OCP\AppFramework\Http;
26use OCP\EventDispatcher\IEventDispatcher;
27use OCP\IDBConnection;
28use OCP\IL10N;
29use Psr\Log\LoggerInterface;
30
31/**
32 * Coordinates an atomic signer removal and publishes notifications after commit.
33 */
34class SignerRemovalService {
35    use TTransactional;
36
37    public function __construct(
38        private FileMapper $fileMapper,
39        private SignRequestMapper $signRequestMapper,
40        private IdentifyMethodService $identifyMethodService,
41        private FileElementMapper $fileElementMapper,
42        private SequentialSigningService $sequentialSigningService,
43        private FileStatusService $fileStatusService,
44        private IEventDispatcher $eventDispatcher,
45        private IL10N $l10n,
46        private IDBConnection $db,
47        private LoggerInterface $logger,
48        private NotificationDeliveryService $notificationDelivery,
49        private NotificationDeliveryMapper $notificationDeliveries,
50    ) {
51    }
52
53    public function remove(int $fileId, int $signRequestId, bool $notify = true): void {
54        $changes = new RemovalChanges();
55        try {
56            $this->atomic(
57                fn () => $this->persistRemoval($fileId, $signRequestId, $changes, $notify),
58                $this->db,
59            );
60        } finally {
61            // The database transaction does not roll back application caches.
62            $this->invalidateCaches($changes);
63        }
64
65        // The cancellation outbox is already committed. Delivery is never
66        // attempted while the removed signer still holds a database lock.
67        $this->notificationDelivery->flushCommitted();
68        $this->sequentialSigningService->notifyActivatedSigners($changes->activatedSigners);
69    }
70
71    private function persistRemoval(int $fileId, int $signRequestId, RemovalChanges $changes, bool $notify): void {
72        $targets = $this->getRemovalTargets($fileId, $signRequestId);
73        [$changes->file, $changes->signRequest] = $targets[0];
74        $changes->identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequestId);
75
76        // Validate the full envelope before deleting anything.
77        foreach ($targets as [$file, $signRequest, $siblings]) {
78            $this->assertCanRemove($file, $signRequest, $siblings);
79            $changes->track($file, $siblings);
80            if ($file->hasParent()) {
81                $parent = $this->fileMapper->getById($file->getParentFileId());
82                $changes->files[$parent->getId()] = $parent;
83            }
84        }
85
86        // Persist cancellation intents in the same transaction as signer removal.
87        // The sender uses an encrypted snapshot after the signer's rows are gone.
88        if ($notify && $changes->file !== null && $changes->signRequest !== null
89            && $changes->signRequest->getStatus() === SignRequestStatus::ABLE_TO_SIGN->value) {
90            $this->notifyCancellation($changes->file, $changes->signRequest, $changes->identifyMethods);
91        }
92
93        foreach ($targets as [$file, $signRequest]) {
94            // Cancel unsent invitations before deleting the signer's rows.
95            // The cancellation notice is a separate event and remains queued.
96            $this->notificationDeliveries->cancelPendingInvitations($signRequest->getId());
97            if (!$this->signRequestMapper->deleteIfUnsigned($signRequest)) {
98                $this->rejectRemoval();
99            }
100            $this->identifyMethodService->deleteBySignRequestId($signRequest->getId());
101            foreach ($this->fileElementMapper->getByFileIdAndSignRequestId($file->getId(), $signRequest->getId()) as $element) {
102                $this->fileElementMapper->delete($element);
103            }
104            foreach ($this->sequentialSigningService->activateAfterDeletion($file, $signRequest->getSigningOrder()) as $activated) {
105                $changes->activated($activated);
106            }
107            $this->revertToDraftWhenEmpty($file);
108        }
109    }
110
111    /**
112     * Lock the entire set of signing requests for each affected file, not only
113     * the removed signer. A concurrent signature must not invalidate preflight.
114     *
115     * @return list<array{FileEntity, SignRequestEntity, list<SignRequestEntity>}>
116     */
117    private function getRemovalTargets(int $fileId, int $signRequestId): array {
118        $rootSigners = $this->signRequestMapper->getByFileIdForUpdate($fileId);
119        $file = $this->fileMapper->getByIdForUpdate($fileId);
120        $targets = [[$file, $this->requireSigner($rootSigners, $signRequestId), $rootSigners]];
121        if (!$file->isEnvelope()) {
122            return $targets;
123        }
124
125        // Match all identifying factors to avoid removing another participant
126        // who only shares an email address or other single factor.
127        // De-duplicate before acquiring locks for child files.
128        $childIds = [];
129        foreach ($this->signRequestMapper->getEnvelopeChildrenForRemoval($fileId, $signRequestId) as $childSigner) {
130            $childIds[$childSigner->getFileId()][$childSigner->getId()] = true;
131        }
132        ksort($childIds);
133
134        foreach ($childIds as $childFileId => $requestIds) {
135            $signers = $this->signRequestMapper->getByFileIdForUpdate($childFileId);
136            $child = $this->fileMapper->getByIdForUpdate($childFileId);
137            if ($child->getParentFileId() !== $fileId) {
138                throw new DoesNotExistException('Envelope child is no longer associated with its parent');
139            }
140            foreach (array_keys($requestIds) as $requestId) {
141                $targets[] = [$child, $this->requireSigner($signers, $requestId), $signers];
142            }
143        }
144
145        return $targets;
146    }
147
148    /**
149     * @param list<SignRequestEntity> $signers
150     */
151    private function requireSigner(array $signers, int $signRequestId): SignRequestEntity {
152        foreach ($signers as $signer) {
153            if ($signer->getId() === $signRequestId) {
154                return $signer;
155            }
156        }
157        throw new DoesNotExistException('Sign request is not associated with the file');
158    }
159
160    /**
161     * @param list<SignRequestEntity> $siblings
162     */
163    private function assertCanRemove(FileEntity $file, SignRequestEntity $signRequest, array $siblings): void {
164        if (!(FileStatus::tryFrom($file->getStatus())?->allowsSignerRemoval() ?? false)
165            || $signRequest->hasCompletedSigning()
166        ) {
167            $this->rejectRemoval();
168        }
169
170        // The file status may not yet reflect another signer's completed update.
171        foreach ($siblings as $sibling) {
172            if ($sibling->hasCompletedSigning()) {
173                $this->rejectRemoval();
174            }
175        }
176    }
177
178    private function rejectRemoval(): never {
179        // TRANSLATORS Error shown when removing a participant would discard a completed signature.
180        throw new LibresignException($this->l10n->t('A completed signer cannot be removed.'), Http::STATUS_UNPROCESSABLE_ENTITY);
181    }
182
183    private function revertToDraftWhenEmpty(FileEntity $file): void {
184        if ($this->signRequestMapper->getByFileId($file->getId()) !== []) {
185            return;
186        }
187        $this->fileStatusService->markPolicySnapshotFrozen($file, FileStatus::DRAFT->value);
188        $file->setStatus(FileStatus::DRAFT->value);
189        $this->fileStatusService->update($file);
190    }
191
192    /**
193     * Cache failure must not turn an already committed removal into an API error,
194     * nor hide the original error if persistence rolled back.
195     */
196    private function invalidateCaches(RemovalChanges $changes): void {
197        foreach ($changes->signRequests as $signRequest) {
198            try {
199                $this->signRequestMapper->invalidateCachedEntity($signRequest);
200            } catch (\Exception $e) {
201                $this->logger->warning('Unable to invalidate signer cache after removal', [
202                    'exception' => $e,
203                    'signRequestId' => $signRequest->getId(),
204                ]);
205            }
206        }
207        foreach ($changes->files as $file) {
208            try {
209                $this->fileMapper->invalidateCachedEntity($file);
210            } catch (\Exception $e) {
211                $this->logger->warning('Unable to invalidate file cache after signer removal', [
212                    'exception' => $e,
213                    'fileId' => $file->getId(),
214                ]);
215            }
216            try {
217                // Invalidate instead of publishing a potentially stale worker status.
218                $this->fileStatusService->invalidateCachedStatus($file);
219            } catch (\Exception $e) {
220                $this->logger->warning('Unable to invalidate file status cache after signer removal', [
221                    'exception' => $e,
222                    'fileId' => $file->getId(),
223                ]);
224            }
225        }
226        $this->identifyMethodService->clearCache();
227    }
228
229    /** @param array<string, array<IIdentifyMethod>> $identifyMethods */
230    private function notifyCancellation(FileEntity $file, SignRequestEntity $signRequest, array $identifyMethods): void {
231        foreach ($identifyMethods as $group) {
232            foreach ($group as $identifyMethod) {
233                // A registration failure must abort deletion: silently dropping
234                // the intent here would violate the transactional outbox contract.
235                $this->eventDispatcher->dispatchTyped(new SignRequestCanceledEvent($signRequest, $file, $identifyMethod));
236            }
237        }
238    }
239}