Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
SignatureRejectionVisibilityService
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
4 / 4
22
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
 hasHiddenRejection
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 presentSigner
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 buildSignerRejection
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
10
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 DateTimeInterface;
12use OCA\Libresign\Db\File as FileEntity;
13use OCA\Libresign\Db\SignRequest as SignRequestEntity;
14use OCA\Libresign\Enum\SignerDisplayStatus;
15use OCA\Libresign\Enum\SignRequestStatus;
16use OCP\IL10N;
17
18/**
19 * Decides how much of a rejection is disclosed to whoever is reading the API.
20 *
21 * Everything is hidden by default: only the person who requested the signature
22 * and the signer who rejected always see the whole record. Other readers see
23 * what the document policy discloses to their audience (see RejectionViewer).
24 *
25 * A comment the signer marked as private is never disclosed to them, whatever
26 * the policy says: the rejection changes the workflow so its status may need to
27 * be shared, but the words the signer wrote are their own.
28 */
29class SignatureRejectionVisibilityService {
30    public function __construct(
31        private SignatureRejectionPolicyService $rejectionPolicyService,
32        private IL10N $l10n,
33    ) {
34    }
35
36    /**
37     * Whether the file holds a rejection the viewer may not know about.
38     *
39     * A rejection is visible to the requester of the file and to the signer
40     * who rejected; everybody else only sees it when the policy discloses it
41     * to their audience. When one is hidden, redacting that signer alone would
42     * still point at them by comparison with the others, so the callers
43     * present every unsigned signer of the file the same way.
44     *
45     * @param SignRequestEntity[] $signers every sign request of the file
46     */
47    public function hasHiddenRejection(?FileEntity $libreSignFile, array $signers, RejectionViewer $viewer): bool {
48        $config = null;
49        foreach ($signers as $signer) {
50            if ($signer->getStatusEnum() !== SignRequestStatus::REJECTED) {
51                continue;
52            }
53            if ($viewer->isPrivilegedFor($signer)) {
54                continue;
55            }
56            $config ??= $this->rejectionPolicyService->getConfig($libreSignFile);
57            if (!$config->isRejectionVisibleTo($viewer->getAudience())) {
58                return true;
59            }
60        }
61        return false;
62    }
63
64    /**
65     * How one signer is presented to the viewer.
66     *
67     * While a rejection is hidden, every unsigned signer is redacted, the
68     * viewer's own pending entry included: if they kept their real status
69     * they could tell who rejected by comparison. The only entry the viewer
70     * keeps is their own rejection, which they already know about.
71     *
72     * @param bool $hiddenRejectionInFile the result of hasHiddenRejection() for the file
73     */
74    public function presentSigner(
75        SignRequestEntity $signer,
76        ?FileEntity $libreSignFile,
77        RejectionViewer $viewer,
78        bool $hiddenRejectionInFile,
79    ): SignerPresentation {
80        $status = $signer->getStatusEnum();
81        // Only a participant who could have rejected is redacted: a signed
82        // signer and an observer say nothing about who rejected, and the
83        // viewer keeps their own rejection.
84        $couldHaveRejected = $status !== SignRequestStatus::SIGNED && $status !== SignRequestStatus::OBSERVING;
85        $ownRejection = $status === SignRequestStatus::REJECTED && $viewer->isPrivilegedFor($signer);
86        if ($hiddenRejectionInFile && $couldHaveRejected && !$ownRejection) {
87            return new SignerPresentation(
88                SignerDisplayStatus::NOT_SIGNED,
89                null,
90                SignerDisplayStatus::NOT_SIGNED->getLabel($this->l10n),
91                null,
92            );
93        }
94
95        return new SignerPresentation(
96            SignerDisplayStatus::fromSignRequestStatus($status),
97            $status->value,
98            $status->getLabel($this->l10n),
99            $this->buildSignerRejection($signer, $libreSignFile, $viewer),
100        );
101    }
102
103    /**
104     * @return array{rejectedAt: string, comment?: string, commentPrivate?: bool}|null
105     */
106    public function buildSignerRejection(
107        SignRequestEntity $signRequest,
108        ?FileEntity $libreSignFile,
109        RejectionViewer $viewer,
110    ): ?array {
111        $rejectedAt = $signRequest->getRejectedAt();
112        if ($signRequest->getStatusEnum() !== SignRequestStatus::REJECTED || $rejectedAt === null) {
113            return null;
114        }
115
116        $privileged = $viewer->isPrivilegedFor($signRequest);
117        $config = $this->rejectionPolicyService->getConfig($libreSignFile);
118        if (!$privileged && !$config->isRejectionVisibleTo($viewer->getAudience())) {
119            return null;
120        }
121
122        $rejection = [
123            'rejectedAt' => $rejectedAt->format(DateTimeInterface::ATOM),
124        ];
125
126        $comment = $signRequest->getRejectionComment();
127        if ($comment === null || $comment === '') {
128            return $rejection;
129        }
130
131        $commentIsPrivate = $signRequest->getRejectionCommentPrivate();
132        if ($privileged) {
133            $rejection['comment'] = $comment;
134            $rejection['commentPrivate'] = $commentIsPrivate;
135            return $rejection;
136        }
137
138        if ($commentIsPrivate || !$config->isCommentVisibleTo($viewer->getAudience())) {
139            return $rejection;
140        }
141
142        $rejection['comment'] = $comment;
143        $rejection['commentPrivate'] = false;
144
145        return $rejection;
146    }
147}