Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
76 / 78
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignRequestService
97.44% covered (success)
97.44%
76 / 78
71.43% covered (warning)
71.43%
5 / 7
31
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
 createOrUpdateSignRequest
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
10
 getSignRequestByIdentifyMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 assertParticipantRoleCanBeUpdated
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 populateSignRequest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 getDisplayNameFromIdentifyMethodIfEmpty
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 insertOrUpdateSignRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\SignRequest;
10
11use OCA\Libresign\Db\SignRequest as SignRequestEntity;
12use OCA\Libresign\Db\SignRequestMapper;
13use OCA\Libresign\Enum\ParticipantRole;
14use OCA\Libresign\Enum\SignRequestStatus;
15use OCA\Libresign\Exception\LibresignException;
16use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
17use OCA\Libresign\Service\IdentifyMethodService;
18use OCA\Libresign\Service\NotificationDeliveryService;
19use OCP\AppFramework\Db\DoesNotExistException;
20use OCP\AppFramework\Db\TTransactional;
21use OCP\IDBConnection;
22use OCP\IL10N;
23use OCP\IUserManager;
24use Sabre\DAV\UUIDUtil;
25
26class SignRequestService {
27    use TTransactional;
28    public function __construct(
29        protected IL10N $l10n,
30        protected SignRequestMapper $signRequestMapper,
31        protected IUserManager $userManager,
32        protected IdentifyMethodService $identifyMethodService,
33        protected StatusService $signRequestStatusService,
34        protected IDBConnection $db,
35        protected NotificationDeliveryService $notifications,
36    ) {
37    }
38
39    /**
40     * Create or update a sign request for a signer
41     *
42     * @param array $identifyMethods Identify methods array
43     * @param string $displayName Signer display name
44     * @param string|null $description Signer description; null keeps the current value, empty string clears it
45     * @param bool $notify Whether to notify the signer
46     * @param int $fileId File ID
47     * @param int $signingOrder Signing order
48     * @param int|null $fileStatus File status
49     * @param int|null $signerStatus Signer status
50     * @param ParticipantRole $participantRole Participant role (signer or observer)
51     * @param callable(SignRequestEntity): void|null $afterPersist Callback invoked after the sign request is persisted and before identify methods are saved
52     * @param string|null $deliveryEventId Shared identity for the explicit request operation, if supplied by its caller
53     * @return SignRequestEntity
54     */
55    public function createOrUpdateSignRequest(
56        array $identifyMethods,
57        string $displayName,
58        ?string $description,
59        bool $notify,
60        int $fileId,
61        int $signingOrder = 0,
62        ?int $fileStatus = null,
63        ?int $signerStatus = null,
64        ParticipantRole $participantRole = ParticipantRole::SIGNER,
65        ?callable $afterPersist = null,
66        ?string $deliveryEventId = null,
67    ): SignRequestEntity {
68        $identifyMethodsInstances = $this->identifyMethodService->getByUserData($identifyMethods);
69        if (empty($identifyMethodsInstances)) {
70            // TRANSLATORS Error shown when the chosen method to identify the signer (account, email, SMS, etc.) is invalid.
71            throw new \Exception($this->l10n->t('Invalid identification method'));
72        }
73
74        $signRequest = $this->getSignRequestByIdentifyMethod(
75            current($identifyMethodsInstances),
76            $fileId
77        );
78
79        $this->assertParticipantRoleCanBeUpdated($signRequest, $participantRole);
80
81        $displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsInstances, $displayName);
82        $this->populateSignRequest($signRequest, $displayName, $signingOrder, $description, $fileId, $participantRole);
83
84        $isNewSignRequest = !$signRequest->getId();
85        $currentStatus = $signRequest->getStatusEnum();
86
87        if ($isNewSignRequest
88            || $currentStatus === SignRequestStatus::DRAFT
89            || $currentStatus === SignRequestStatus::ABLE_TO_SIGN
90            || $currentStatus === SignRequestStatus::OBSERVING
91        ) {
92            $desiredStatus = $this->signRequestStatusService->determineInitialStatus(
93                $signingOrder,
94                $fileId,
95                $fileStatus,
96                $signerStatus,
97                $currentStatus,
98                $participantRole,
99            );
100            $this->signRequestStatusService->updateStatusIfAllowed($signRequest, $currentStatus, $desiredStatus, $isNewSignRequest);
101        }
102
103        $this->atomic(function () use ($signRequest, $afterPersist, $notify, $fileStatus, $deliveryEventId, $identifyMethodsInstances): void {
104            $this->insertOrUpdateSignRequest($signRequest);
105
106            if ($afterPersist !== null) {
107                $afterPersist($signRequest);
108            }
109
110            $shouldNotify = $notify && $this->signRequestStatusService->shouldNotifySignRequest(
111                $signRequest->getStatusEnum(),
112                $fileStatus
113            );
114
115            // A new explicit write is a new event even if its visible fields did
116            // not change. Repeated dispatch of this same event still deduplicates.
117            // All factors of one request share the same event identity.
118            $eventId = $shouldNotify
119                ? ($deliveryEventId ?? 'sign-request-write:' . bin2hex(random_bytes(16)))
120                : null;
121            foreach ($identifyMethodsInstances as $identifyMethod) {
122                $identifyMethod->getEntity()->setSignRequestId($signRequest->getId());
123                $identifyMethod->willNotifyUser($shouldNotify);
124                $identifyMethod->save($eventId);
125            }
126        }, $this->db);
127        $this->notifications->flushCommitted();
128        return $signRequest;
129    }
130
131    public function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, int $fileId): SignRequestEntity {
132        try {
133            $signRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId($identifyMethod, $fileId);
134        } catch (DoesNotExistException) {
135            $signRequest = new SignRequestEntity();
136        }
137        return $signRequest;
138    }
139
140    private function assertParticipantRoleCanBeUpdated(
141        SignRequestEntity $signRequest,
142        ParticipantRole $participantRole,
143    ): void {
144        if (!$signRequest->getId()) {
145            return;
146        }
147
148        if ($signRequest->getStatusEnum() !== SignRequestStatus::SIGNED) {
149            return;
150        }
151
152        if ($signRequest->getParticipantRoleEnum() === $participantRole) {
153            return;
154        }
155
156        // TRANSLATORS Error shown when trying to change the participant role after the document was already signed.
157        throw new LibresignException($this->l10n->t('Cannot change the participant role after the document has been signed'));
158    }
159
160    private function populateSignRequest(
161        SignRequestEntity $signRequest,
162        string $displayName,
163        int $signingOrder,
164        ?string $description,
165        int $fileId,
166        ParticipantRole $participantRole,
167    ): void {
168        $signRequest->setFileId($fileId);
169        $signRequest->setSigningOrder($signingOrder);
170        $signRequest->setParticipantRoleEnum($participantRole);
171        if (!$signRequest->getUuid()) {
172            $signRequest->setUuid(UUIDUtil::getUUID());
173        }
174        if (!empty($displayName)) {
175            $signRequest->setDisplayName($displayName);
176        }
177        if ($description !== null) {
178            $signRequest->setDescription(trim($description) === '' ? null : $description);
179        }
180        if (!$signRequest->getId()) {
181            $signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
182        }
183    }
184
185    /**
186     * @param IIdentifyMethod[] $identifyMethodsInstances
187     * @param string $displayName
188     * @return string
189     */
190    private function getDisplayNameFromIdentifyMethodIfEmpty(array $identifyMethodsInstances, string $displayName): string {
191        if (!empty($displayName)) {
192            return $displayName;
193        }
194        foreach ($identifyMethodsInstances as $identifyMethod) {
195            if ($identifyMethod->getName() === 'account') {
196                return $this->userManager->get($identifyMethod->getEntity()->getIdentifierValue())->getDisplayName();
197            }
198        }
199        foreach ($identifyMethodsInstances as $identifyMethod) {
200            if ($identifyMethod->getName() !== 'account') {
201                return $identifyMethod->getEntity()->getIdentifierValue();
202            }
203        }
204        return '';
205    }
206
207    public function insertOrUpdateSignRequest(SignRequestEntity $signRequest): void {
208        if ($signRequest->getId()) {
209            $this->signRequestMapper->update($signRequest);
210        } else {
211            $this->signRequestMapper->insert($signRequest);
212        }
213    }
214}