Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.30% covered (success)
97.30%
36 / 37
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SigningWorkflowStatusResolver
97.30% covered (success)
97.30%
36 / 37
80.00% covered (warning)
80.00%
4 / 5
14
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
 resolve
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 workflowEndedDespiteRejections
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 excludeIdDocUploaderPlaceholder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getIdDocUploaderSignRequestId
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
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\File;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\IdDocs;
13use OCA\Libresign\Db\IdDocsMapper;
14use OCA\Libresign\Db\SignRequest as SignRequestEntity;
15use OCA\Libresign\Enum\FileStatus;
16use OCA\Libresign\Enum\SignatureRejectionBehavior;
17use OCA\Libresign\Enum\SignRequestStatus;
18use OCA\Libresign\Service\SignatureRejection\SignatureRejectionPolicyService;
19use OCP\AppFramework\Db\DoesNotExistException;
20
21/**
22 * Works out the status a document reaches from what its signers did. A
23 * signature and a rejection both end a signer's turn, so both flows resolve
24 * the status here and cannot disagree about when the workflow is over.
25 */
26class SigningWorkflowStatusResolver {
27    public function __construct(
28        private IdDocsMapper $idDocsMapper,
29        private SignatureRejectionPolicyService $signatureRejectionPolicyService,
30    ) {
31    }
32
33    /**
34     * @param SignRequestEntity[] $signRequests every sign request of the document
35     * @return FileStatus|null null when the signers give no reason to change the status
36     */
37    public function resolve(FileEntity $file, array $signRequests): ?FileStatus {
38        $signers = $this->excludeIdDocUploaderPlaceholder($file, $signRequests);
39        $signers = array_values(array_filter(
40            $signers,
41            static fn (SignRequestEntity $signer): bool => !$signer->isObserver(),
42        ));
43
44        $total = count($signers);
45
46        if ($total === 0) {
47            return null;
48        }
49
50        $totalSigned = count(array_filter($signers, static fn (SignRequestEntity $signer): bool => $signer->getSigned() !== null));
51
52        if ($totalSigned === $total) {
53            return FileStatus::SIGNED;
54        }
55
56        if ($this->workflowEndedDespiteRejections($file, $signers, $totalSigned)) {
57            return $totalSigned > 0 ? FileStatus::SIGNED : FileStatus::CANCELED;
58        }
59
60        if ($totalSigned > 0) {
61            return FileStatus::PARTIAL_SIGNED;
62        }
63
64        return null;
65    }
66
67    /**
68     * A rejection configured not to cancel the workflow leaves behind a signer
69     * who will never sign, so counting signatures alone would keep the file
70     * partially signed forever. The workflow is over once nobody is expected to
71     * act any more: it is complete when at least one signature was applied, and
72     * canceled when every signer rejected.
73     *
74     * A rejected signer keeps its own status: only the file-level status says
75     * how the configured workflow finished.
76     *
77     * @param SignRequestEntity[] $signers
78     */
79    private function workflowEndedDespiteRejections(FileEntity $file, array $signers, int $totalSigned): bool {
80        $totalRejected = count(array_filter(
81            $signers,
82            static fn (SignRequestEntity $signer): bool => $signer->getStatusEnum() === SignRequestStatus::REJECTED,
83        ));
84
85        if ($totalSigned + $totalRejected !== count($signers)) {
86            return false;
87        }
88
89        return $this->signatureRejectionPolicyService->getBehavior($file) === SignatureRejectionBehavior::CONTINUE;
90    }
91
92    /**
93     * @param SignRequestEntity[] $signRequests
94     * @return SignRequestEntity[]
95     */
96    private function excludeIdDocUploaderPlaceholder(FileEntity $file, array $signRequests): array {
97        $placeholderId = $this->getIdDocUploaderSignRequestId($file);
98        if ($placeholderId === null) {
99            return $signRequests;
100        }
101
102        return array_values(array_filter(
103            $signRequests,
104            static fn (SignRequestEntity $signer): bool => $signer->getId() !== $placeholderId,
105        ));
106    }
107
108    private function getIdDocUploaderSignRequestId(FileEntity $file): ?int {
109        try {
110            $idDocs = $this->idDocsMapper->getByFileId($file->getId());
111        } catch (DoesNotExistException) {
112            return null;
113        }
114
115        if (!$idDocs instanceof IdDocs) {
116            return null;
117        }
118
119        return $idDocs->getSignRequestId();
120    }
121}