Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
SignatureRejectionService
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
14 / 14
49
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reject
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isRejected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWorkflowCanceled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 persistRejection
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 collectSignRequestsToReject
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 releaseNextSigningOrders
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 resolveStatusOfRejectedDocuments
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 cancelWorkflow
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 restoreState
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 assertWorkflowIsOpen
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 assertSignerCanReject
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 normalizeComment
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 dispatchRejectedEvent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
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\SignatureRejection;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\SignRequest as SignRequestEntity;
14use OCA\Libresign\Db\SignRequestMapper;
15use OCA\Libresign\Enum\FileStatus;
16use OCA\Libresign\Enum\SignatureRejectionCommentMode;
17use OCA\Libresign\Enum\SignRequestStatus;
18use OCA\Libresign\Events\SignatureRejectedEvent;
19use OCA\Libresign\Exception\LibresignException;
20use OCA\Libresign\Service\File\SigningWorkflowStatusResolver;
21use OCA\Libresign\Service\FileStatusService;
22use OCA\Libresign\Service\IdentifyMethodService;
23use OCA\Libresign\Service\SequentialSigningService;
24use OCP\AppFramework\Utility\ITimeFactory;
25use OCP\EventDispatcher\IEventDispatcher;
26use OCP\IDBConnection;
27use OCP\IL10N;
28use Psr\Log\LoggerInterface;
29
30/**
31 * Records a signer's refusal to sign a document.
32 *
33 * The rules that govern a rejection (whether it is allowed at all, whether a
34 * comment is required, and whether the remaining workflow keeps running) come
35 * from the signature rejection policy frozen on the document.
36 */
37class SignatureRejectionService {
38    public const MAX_COMMENT_LENGTH = 4096;
39
40    public function __construct(
41        private SignRequestMapper $signRequestMapper,
42        private FileMapper $fileMapper,
43        private SignatureRejectionPolicyService $rejectionPolicyService,
44        private FileStatusService $fileStatusService,
45        private IdentifyMethodService $identifyMethodService,
46        private SequentialSigningService $sequentialSigningService,
47        private SigningWorkflowStatusResolver $signingWorkflowStatusResolver,
48        private IEventDispatcher $eventDispatcher,
49        private IDBConnection $db,
50        private ITimeFactory $timeFactory,
51        private IL10N $l10n,
52        private LoggerInterface $logger,
53    ) {
54    }
55
56    /**
57     * @throws LibresignException when the policy or the current workflow state forbids the rejection
58     */
59    public function reject(
60        FileEntity $libreSignFile,
61        SignRequestEntity $signRequest,
62        ?string $comment = null,
63        bool $privateComment = false,
64    ): SignRequestEntity {
65        $config = $this->rejectionPolicyService->getConfig($libreSignFile);
66
67        if (!$config->isEnabled()) {
68            // TRANSLATORS Error shown when a signer tries to reject a document whose policy does not allow rejections.
69            throw new LibresignException($this->l10n->t('Signature rejection is not enabled for this document.'));
70        }
71
72        $this->assertWorkflowIsOpen($libreSignFile);
73        $this->assertSignerCanReject($signRequest);
74
75        $normalizedComment = $this->normalizeComment($comment, $config->getCommentMode());
76        // A comment can carry personal or legal information, so keeping it private is
77        // always the signer's call and no policy can take that choice away.
78        $commentIsPrivate = $normalizedComment !== null && $privateComment;
79
80        $activatedSigners = $this->persistRejection($libreSignFile, $signRequest, $normalizedComment, $commentIsPrivate, $config->cancelsWorkflow());
81        // Record the committed rejection before notification errors can interrupt this flow.
82        // The event reports the status the rejection left, as the API does: a workflow that
83        // continues after rejections is still canceled once every signer rejected.
84        $this->dispatchRejectedEvent($signRequest, $libreSignFile, $this->isWorkflowCanceled($libreSignFile));
85        $this->sequentialSigningService->notifyActivatedSigners($activatedSigners);
86
87        return $signRequest;
88    }
89
90    public function isRejected(SignRequestEntity $signRequest): bool {
91        return $signRequest->getStatusEnum() === SignRequestStatus::REJECTED;
92    }
93
94    public function isWorkflowCanceled(FileEntity $libreSignFile): bool {
95        return $libreSignFile->getStatus() === FileStatus::CANCELED->value;
96    }
97
98    /**
99     * Marking the signer as rejected and closing the workflow describe a single
100     * decision, so they are written together: a failure must never leave a signer
101     * who already rejected on a document that stays open for everybody else.
102     *
103     * @return list<SignRequestEntity> Signers activated by the committed rejection.
104     */
105    private function persistRejection(
106        FileEntity $libreSignFile,
107        SignRequestEntity $signRequest,
108        ?string $comment,
109        bool $commentIsPrivate,
110        bool $cancelWorkflow,
111    ): array {
112        $signRequests = $this->collectSignRequestsToReject($libreSignFile, $signRequest);
113        $previousSignerStatuses = array_map(
114            static fn (SignRequestEntity $each): int => $each->getStatus(),
115            $signRequests,
116        );
117        $previousFileStatus = $libreSignFile->getStatus();
118        $rejectedAt = $this->timeFactory->getDateTime();
119
120        $this->db->beginTransaction();
121        try {
122            foreach ($signRequests as $each) {
123                $each->setStatusEnum(SignRequestStatus::REJECTED);
124                $each->setRejectedAt($rejectedAt);
125                $each->setRejectionComment($comment);
126                $each->setRejectionCommentPrivate($commentIsPrivate);
127                $this->signRequestMapper->update($each);
128            }
129
130            if ($cancelWorkflow) {
131                $this->cancelWorkflow($libreSignFile);
132                $activatedSigners = [];
133            } else {
134                $activatedSigners = $this->releaseNextSigningOrders($libreSignFile, $signRequests);
135                $this->resolveStatusOfRejectedDocuments($libreSignFile, $signRequests);
136            }
137
138            $this->db->commit();
139        } catch (\Throwable $e) {
140            $this->db->rollBack();
141            $this->restoreState($libreSignFile, $signRequests, $previousSignerStatuses, $previousFileStatus);
142            $this->logger->error('Failed to register the signature rejection: ' . $e->getMessage(), [
143                'exception' => $e,
144                'signRequestId' => $signRequest->getId(),
145            ]);
146
147            // TRANSLATORS Error shown when the rejection could not be saved and nothing was changed.
148            throw new LibresignException($this->l10n->t('It was not possible to register the rejection. Nothing was changed.'));
149        }
150
151        return $activatedSigners;
152    }
153
154    /**
155     * A signer of an envelope has one signature request per document it contains,
156     * plus one on the envelope itself. Signing closes all of them, so a rejection
157     * has to close all of them too: recording it on a single document would leave
158     * the refusal invisible to the person who requested the signature, whose view
159     * of the envelope is built from the envelope-level signature requests.
160     *
161     * @return list<SignRequestEntity>
162     */
163    private function collectSignRequestsToReject(FileEntity $libreSignFile, SignRequestEntity $signRequest): array {
164        $envelopeId = $libreSignFile->isEnvelope()
165            ? $libreSignFile->getId()
166            : $libreSignFile->getParentFileId();
167        if ($envelopeId === null) {
168            return [$signRequest];
169        }
170
171        $collected = [$signRequest->getId() => $signRequest];
172
173        foreach ($this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod($envelopeId, $signRequest->getId()) as $childSignRequest) {
174            $collected[$childSignRequest->getId()] ??= $childSignRequest;
175        }
176
177        try {
178            $envelopeSignRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId(
179                $this->identifyMethodService->getIdentifiedMethod($signRequest->getId()),
180                $envelopeId,
181            );
182            $collected[$envelopeSignRequest->getId()] ??= $envelopeSignRequest;
183        } catch (\Throwable) {
184            // The envelope itself carries no signature request for this signer.
185        }
186
187        // A document this signer already signed keeps its signature.
188        return array_values(array_filter(
189            $collected,
190            static fn (SignRequestEntity $each): bool => $each->getSigned() === null,
191        ));
192    }
193
194    /**
195     * When the workflow continues, a rejection ends the signer's turn just like a
196     * signature does, so in a sequential flow the next order must be released on
197     * every document the signer rejected, or the signers after them would wait
198     * forever.
199     *
200     * @param list<SignRequestEntity> $rejectedSignRequests
201     * @return list<SignRequestEntity> The signers activated on every document.
202     */
203    private function releaseNextSigningOrders(FileEntity $libreSignFile, array $rejectedSignRequests): array {
204        $activatedSigners = [];
205
206        foreach ($rejectedSignRequests as $rejected) {
207            $document = $rejected->getFileId() === $libreSignFile->getId()
208                ? $libreSignFile
209                : $this->fileMapper->getById($rejected->getFileId());
210            foreach ($this->sequentialSigningService
211                ->setFile($document)
212                ->activateNextOrder(
213                    $rejected->getFileId(),
214                    $rejected->getSigningOrder()
215                ) as $activated) {
216                $activatedSigners[] = $activated;
217            }
218        }
219
220        return $activatedSigners;
221    }
222
223    /**
224     * A rejection ends the signer's turn just like a signature does, so it can be
225     * the last action a document was waiting for. Its status is then resolved by
226     * the same rule the signing flow uses, otherwise a document whose last signer
227     * rejected would stay partially signed forever.
228     *
229     * An envelope is not resolved here: its status follows the documents it
230     * contains, so it is read back once they are saved.
231     *
232     * @param list<SignRequestEntity> $rejectedSignRequests
233     */
234    private function resolveStatusOfRejectedDocuments(FileEntity $libreSignFile, array $rejectedSignRequests): void {
235        foreach ($rejectedSignRequests as $rejected) {
236            $document = $rejected->getFileId() === $libreSignFile->getId()
237                ? $libreSignFile
238                : $this->fileMapper->getById($rejected->getFileId());
239            if ($document->isEnvelope()) {
240                continue;
241            }
242
243            $status = $this->signingWorkflowStatusResolver->resolve(
244                $document,
245                $this->signRequestMapper->getByFileId($document->getId()),
246            );
247            if ($status === null || $status->value === $document->getStatus()) {
248                continue;
249            }
250
251            $document->setStatusEnum($status);
252            $this->fileStatusService->update($document);
253        }
254
255        if ($libreSignFile->isEnvelope()) {
256            $libreSignFile->setStatus($this->fileMapper->getById($libreSignFile->getId())->getStatus());
257        }
258    }
259
260    /**
261     * A rejection closes the whole envelope, not only the document that carried
262     * the rejected signature request.
263     */
264    private function cancelWorkflow(FileEntity $libreSignFile): void {
265        $libreSignFile->setStatusEnum(FileStatus::CANCELED);
266        $this->fileStatusService->update($libreSignFile);
267
268        $envelopeId = $libreSignFile->isEnvelope()
269            ? $libreSignFile->getId()
270            : $libreSignFile->getParentFileId();
271        if ($envelopeId === null) {
272            return;
273        }
274
275        $this->fileStatusService->propagateStatusToChildren($envelopeId, FileStatus::CANCELED->value);
276
277        if (!$libreSignFile->isEnvelope()) {
278            $envelope = $this->fileMapper->getById($envelopeId);
279            $envelope->setStatusEnum(FileStatus::CANCELED);
280            $this->fileStatusService->update($envelope);
281        }
282    }
283
284    /**
285     * @param list<SignRequestEntity> $signRequests
286     * @param list<int> $previousSignerStatuses
287     */
288    private function restoreState(
289        FileEntity $libreSignFile,
290        array $signRequests,
291        array $previousSignerStatuses,
292        int $previousFileStatus,
293    ): void {
294        foreach ($signRequests as $index => $signRequest) {
295            $signRequest->setStatus($previousSignerStatuses[$index]);
296            $signRequest->setRejectedAt(null);
297            $signRequest->setRejectionComment(null);
298            $signRequest->setRejectionCommentPrivate(false);
299        }
300        $libreSignFile->setStatus($previousFileStatus);
301    }
302
303    private function assertWorkflowIsOpen(FileEntity $libreSignFile): void {
304        $openStatusList = [
305            FileStatus::ABLE_TO_SIGN->value,
306            FileStatus::PARTIAL_SIGNED->value,
307        ];
308        if (in_array($libreSignFile->getStatus(), $openStatusList, true)) {
309            return;
310        }
311
312        if ($libreSignFile->getStatus() === FileStatus::CANCELED->value) {
313            // TRANSLATORS Error shown when an action is attempted on a document whose signing workflow was already closed by a rejection.
314            throw new LibresignException($this->l10n->t('The signing workflow of this document is already closed.'));
315        }
316
317        // TRANSLATORS Error shown when a signer tries to reject a document that is not currently open for signing.
318        throw new LibresignException($this->l10n->t('This document is not open for signature rejection.'));
319    }
320
321    private function assertSignerCanReject(SignRequestEntity $signRequest): void {
322        if ($signRequest->getSigned() !== null) {
323            // TRANSLATORS Error shown when the current user already signed this document.
324            throw new LibresignException($this->l10n->t('File already signed by you'));
325        }
326
327        if ($this->isRejected($signRequest)) {
328            // TRANSLATORS Error shown when a signer tries to reject the same signature request twice.
329            throw new LibresignException($this->l10n->t('You already rejected this signature request.'));
330        }
331    }
332
333    private function normalizeComment(?string $comment, SignatureRejectionCommentMode $mode): ?string {
334        $trimmed = $comment === null ? '' : trim($comment);
335
336        if ($mode === SignatureRejectionCommentMode::DISABLED) {
337            if ($trimmed !== '') {
338                // TRANSLATORS Error shown when a signer sends a rejection comment but the document policy does not accept comments.
339                throw new LibresignException($this->l10n->t('Rejection comments are not allowed for this document.'));
340            }
341            return null;
342        }
343
344        if ($trimmed === '') {
345            if ($mode === SignatureRejectionCommentMode::REQUIRED) {
346                // TRANSLATORS Error shown when the document policy requires a justification and the signer did not provide one.
347                throw new LibresignException($this->l10n->t('A comment is required to reject this signature request.'));
348            }
349            return null;
350        }
351
352        if (mb_strlen($trimmed) > self::MAX_COMMENT_LENGTH) {
353            // TRANSLATORS Error shown when the rejection justification exceeds the maximum accepted length. %s is the maximum number of characters.
354            throw new LibresignException($this->l10n->t('The rejection comment must have at most %s characters.', [(string)self::MAX_COMMENT_LENGTH]));
355        }
356
357        return $trimmed;
358    }
359
360    private function dispatchRejectedEvent(
361        SignRequestEntity $signRequest,
362        FileEntity $libreSignFile,
363        bool $workflowCanceled,
364    ): void {
365        // The rejection is already committed at this point: a failing listener must
366        // not turn a recorded rejection into an error for the signer.
367        try {
368            $groupedIdentifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
369            foreach ($groupedIdentifyMethods as $identifyMethods) {
370                foreach ($identifyMethods as $identifyMethod) {
371                    $this->eventDispatcher->dispatchTyped(new SignatureRejectedEvent(
372                        $signRequest,
373                        $libreSignFile,
374                        $identifyMethod,
375                        $workflowCanceled,
376                    ));
377                }
378            }
379        } catch (\Throwable $e) {
380            $this->logger->error('Error dispatching SignatureRejectedEvent: ' . $e->getMessage(), ['exception' => $e]);
381        }
382    }
383}