Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.10% covered (success)
99.10%
110 / 111
94.74% covered (success)
94.74%
18 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SequentialSigningService
99.10% covered (success)
99.10%
110 / 111
94.74% covered (success)
94.74%
18 / 19
58
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
 setFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isOrderedNumericFlow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetOrderCounter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 determineSigningOrder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 releaseNextOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activateNextOrder
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 notifyActivatedSigners
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 notifySigner
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 reorderAfterDeletion
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 activateAfterDeletion
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 isOrderFullyCompleted
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findNextOrder
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 activateSignersForOrder
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getSignatureFlow
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasPendingLowerOrderSigners
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 isSigningParticipant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isStatusUpgrade
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateStatusByOrder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
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;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\SignRequest as SignRequestEntity;
13use OCA\Libresign\Db\SignRequestMapper;
14use OCA\Libresign\Enum\SignatureFlow;
15use OCA\Libresign\Enum\SignRequestStatus;
16use Psr\Log\LoggerInterface;
17
18class SequentialSigningService {
19    private int $currentOrder = 1;
20    private ?FileEntity $file = null;
21
22    public function __construct(
23        private SignRequestMapper $signRequestMapper,
24        private IdentifyMethodService $identifyMethodService,
25        private LoggerInterface $logger,
26    ) {
27    }
28
29    public function setFile(FileEntity $file): self {
30        $this->file = $file;
31        return $this;
32    }
33
34    public function isOrderedNumericFlow(): bool {
35        return $this->getSignatureFlow() === SignatureFlow::ORDERED_NUMERIC;
36    }
37
38    /**
39     * Reset the internal order counter
40     */
41    public function resetOrderCounter(): void {
42        $this->currentOrder = 1;
43    }
44
45    /**
46     * Determine signing order based on flow configuration
47     * Manages internal counter automatically
48     *
49     * @param int|null $userProvidedOrder Order explicitly set by user
50     * @return int The order to use
51     */
52    public function determineSigningOrder(?int $userProvidedOrder): int {
53        if (!$this->isOrderedNumericFlow()) {
54            return 1;
55        }
56
57        if ($userProvidedOrder !== null) {
58            if ($userProvidedOrder >= $this->currentOrder) {
59                $this->currentOrder = $userProvidedOrder + 1;
60            }
61            return $userProvidedOrder;
62        }
63
64        return $this->currentOrder++;
65    }
66
67    /**
68     * Release next order of signers after current order is completed
69     * Called when a signature is saved
70     *
71     * @param int $fileId
72     * @param int $completedOrder The order that was just completed
73     */
74    public function releaseNextOrder(int $fileId, int $completedOrder): void {
75        $this->notifyActivatedSigners($this->activateNextOrder($fileId, $completedOrder));
76    }
77
78    /**
79     * Same as releaseNextOrder(), but it only persists the activation and returns
80     * the signers it activated instead of notifying them.
81     *
82     * A caller running inside a transaction must collect the returned signers and
83     * call notifyActivatedSigners() only after the commit: a notification sent
84     * before the commit is already delivered when a rollback reverts the
85     * activation it announced.
86     *
87     * @return list<SignRequestEntity> The signers moved to ABLE_TO_SIGN.
88     */
89    public function activateNextOrder(int $fileId, int $completedOrder): array {
90        if (!$this->isOrderedNumericFlow()) {
91            return [];
92        }
93
94        $allSignRequests = $this->signRequestMapper->getByFileId($fileId);
95
96        if (!$this->isOrderFullyCompleted($allSignRequests, $completedOrder)) {
97            return [];
98        }
99
100        $nextOrder = $this->findNextOrder($allSignRequests, $completedOrder);
101        if ($nextOrder === null) {
102            return [];
103        }
104
105        return $this->activateSignersForOrder($allSignRequests, $nextOrder);
106    }
107
108    /**
109     * @param list<SignRequestEntity> $signers
110     */
111    public function notifyActivatedSigners(array $signers): void {
112        foreach ($signers as $signer) {
113            $this->notifySigner($signer);
114        }
115    }
116
117    /**
118     * Isolate recoverable lookup and delivery failures so other signers can proceed.
119     * Channel exceptions vary; programming errors must still propagate.
120     */
121    private function notifySigner(SignRequestEntity $signer): void {
122        try {
123            $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signer->getId());
124        } catch (\Exception $e) {
125            $this->logger->error('Error loading identification methods for an activated signer: ' . $e->getMessage(), [
126                'exception' => $e,
127                'signRequestId' => $signer->getId(),
128            ]);
129            return;
130        }
131
132        foreach ($identifyMethods as $methodGroup) {
133            foreach ($methodGroup as $identifyMethod) {
134                try {
135                    $identifyMethod->willNotifyUser(true);
136                    $identifyMethod->notify();
137                } catch (\Exception $e) {
138                    $this->logger->error('Error notifying an activated signer: ' . $e->getMessage(), [
139                        'exception' => $e,
140                        'signRequestId' => $signer->getId(),
141                        'identifyMethod' => $identifyMethod->getName(),
142                    ]);
143                }
144            }
145        }
146    }
147
148    /**
149     * Reorder and activate signers after a SignRequest deletion
150     * This ensures no gaps in the signing sequence
151     *
152     * @param int $fileId The file ID
153     * @param int $deletedOrder The order that was deleted
154     */
155    public function reorderAfterDeletion(int $fileId, int $deletedOrder): void {
156        if ($this->file === null || $this->file->getId() !== $fileId) {
157            throw new \LogicException('File must be set before reordering signers');
158        }
159        $this->notifyActivatedSigners($this->activateAfterDeletion($this->file, $deletedOrder));
160    }
161
162    /**
163     * Persist the next activation without external notifications while a
164     * transaction is open. The caller sends notifications after commit.
165     *
166     * @return list<SignRequestEntity>
167     */
168    public function activateAfterDeletion(FileEntity $file, int $deletedOrder): array {
169        if ($deletedOrder < 1 || $file->getSignatureFlowEnum() !== SignatureFlow::ORDERED_NUMERIC) {
170            return [];
171        }
172
173        $allSignRequests = $this->signRequestMapper->getByFileId($file->getId());
174        $hasSignersAtDeletedOrder = array_filter(
175            $allSignRequests,
176            static fn (SignRequestEntity $sr): bool => $sr->getSigningOrder() === $deletedOrder,
177        );
178        if ($hasSignersAtDeletedOrder !== []) {
179            return [];
180        }
181
182        $previousOrder = $deletedOrder - 1;
183        if ($previousOrder > 0 && !$this->isOrderFullyCompleted($allSignRequests, $previousOrder)) {
184            return [];
185        }
186
187        $nextOrder = $this->findNextOrder($allSignRequests, $deletedOrder);
188        return $nextOrder !== null ? $this->activateSignersForOrder($allSignRequests, $nextOrder) : [];
189    }
190
191    /**
192     * A rejected signer will never sign, so like in hasPendingLowerOrderSigners()
193     * it must not hold the next order back when the workflow continues after a
194     * rejection.
195     */
196    private function isOrderFullyCompleted(array $signRequests, int $order): bool {
197        $pendingSigners = array_filter(
198            $signRequests,
199            fn ($sr) => $this->isSigningParticipant($sr)
200                && $sr->getSigningOrder() === $order
201                && $sr->getStatusEnum() !== SignRequestStatus::SIGNED
202                && $sr->getStatusEnum() !== SignRequestStatus::REJECTED
203        );
204
205        return empty($pendingSigners);
206    }
207
208    private function findNextOrder(array $signRequests, int $completedOrder): ?int {
209        $allOrders = array_unique(array_map(
210            fn ($sr) => $this->isSigningParticipant($sr) ? $sr->getSigningOrder() : null,
211            $signRequests,
212        ));
213        $allOrders = array_values(array_filter($allOrders, static fn (?int $order) => $order !== null && $order > 0));
214        sort($allOrders);
215
216        foreach ($allOrders as $order) {
217            if ($order > $completedOrder) {
218                return $order;
219            }
220        }
221
222        return null;
223    }
224
225    /**
226     * @param list<SignRequestEntity> $signRequests
227     * @return list<SignRequestEntity> The signers moved to ABLE_TO_SIGN.
228     */
229    private function activateSignersForOrder(array $signRequests, int $order): array {
230        $activated = [];
231        $signersToActivate = array_filter(
232            $signRequests,
233            fn ($sr) => $this->isSigningParticipant($sr)
234                && $sr->getSigningOrder() === $order
235        );
236
237        foreach ($signersToActivate as $signer) {
238            if ($signer->getStatusEnum() === SignRequestStatus::DRAFT) {
239                $signer->setStatusEnum(SignRequestStatus::ABLE_TO_SIGN);
240                $this->signRequestMapper->update($signer);
241                $activated[] = $signer;
242            }
243        }
244
245        return $activated;
246    }
247
248    private function getSignatureFlow(): SignatureFlow {
249        if ($this->file === null) {
250            throw new \LogicException('File must be set before calling getSignatureFlow(). Call setFile() first.');
251        }
252        return $this->file->getSignatureFlowEnum();
253    }
254
255    /**
256     * Check if there are signers with lower signing order that haven't signed yet
257     */
258    public function hasPendingLowerOrderSigners(int $fileId, int $currentOrder): bool {
259        $signRequests = $this->signRequestMapper->getByFileId($fileId);
260
261        foreach ($signRequests as $signRequest) {
262            if (!$this->isSigningParticipant($signRequest)) {
263                continue;
264            }
265
266            $order = $signRequest->getSigningOrder();
267            $status = $signRequest->getStatusEnum();
268
269            // If a signer with lower order hasn't signed yet, return true.
270            // A rejected signer will never sign, so it must not block the remaining
271            // signers when the workflow is configured to continue after a rejection.
272            if ($order < $currentOrder
273                && $status !== SignRequestStatus::SIGNED
274                && $status !== SignRequestStatus::REJECTED
275            ) {
276                return true;
277            }
278        }
279
280        return false;
281    }
282
283    private function isSigningParticipant(\OCA\Libresign\Db\SignRequest $signRequest): bool {
284        return $signRequest->getParticipantRoleEnum()->canSign();
285    }
286
287    /**
288     * Check if changing from currentStatus to desiredStatus is an upgrade (or same level)
289     * Status hierarchy: DRAFT (0) < ABLE_TO_SIGN (1) < SIGNED (2)
290     */
291    public function isStatusUpgrade(
292        SignRequestStatus $currentStatus,
293        SignRequestStatus $desiredStatus,
294    ): bool {
295        return $desiredStatus->value >= $currentStatus->value;
296    }
297
298    /**
299     * Validate if a signer can transition to ABLE_TO_SIGN status based on signing order
300     * In ordered numeric flow, prevents skipping ahead if lower-order signers haven't signed
301     *
302     * @param SignRequestStatus $desiredStatus The status being requested
303     * @param int $signingOrder The signer's order
304     * @param int $fileId The file ID
305     * @return SignRequestStatus The validated status (may return DRAFT if validation fails)
306     */
307    public function validateStatusByOrder(
308        SignRequestStatus $desiredStatus,
309        int $signingOrder,
310        int $fileId,
311    ): SignRequestStatus {
312        // Only validate for ordered numeric flow
313        if (!$this->isOrderedNumericFlow()) {
314            return $desiredStatus;
315        }
316
317        // Only validate when trying to set ABLE_TO_SIGN and not the first signer
318        if ($desiredStatus !== SignRequestStatus::ABLE_TO_SIGN || $signingOrder <= 1) {
319            return $desiredStatus;
320        }
321
322        // Check if any lower order signers haven't signed yet
323        if ($this->hasPendingLowerOrderSigners($fileId, $signingOrder)) {
324            return SignRequestStatus::DRAFT;
325        }
326
327        return $desiredStatus;
328    }
329}