Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.80% covered (success)
97.80%
89 / 91
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileStatusService
97.80% covered (success)
97.80%
89 / 91
85.71% covered (warning)
85.71%
12 / 14
39
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
 updateFileStatusIfUpgrade
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 invalidateCachedStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canNotifySigners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 propagateStatusToParent
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
 updateForQueuedJob
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 discardQueuedJobStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onQueuedJobCommitted
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 update
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 propagateStatusToChildren
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 markPolicySnapshotFrozen
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isPolicySnapshotFrozen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 hasFrozenPolicySnapshot
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 touchStatusChangedAt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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 DateTime;
12use DateTimeInterface;
13use OCA\Libresign\Db\File as FileEntity;
14use OCA\Libresign\Db\FileMapper;
15use OCA\Libresign\Enum\FileStatus;
16use OCA\Libresign\Service\SignRequest\StatusCacheService;
17use OCP\AppFramework\Db\DoesNotExistException;
18
19class FileStatusService {
20    public const POLICY_SNAPSHOT_FROZEN_AT = 'policy_snapshot_frozen_at';
21
22    public function __construct(
23        private FileMapper $fileMapper,
24        private StatusCacheService $statusCacheService,
25    ) {
26    }
27
28    public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus): FileEntity {
29        $currentStatus = $file->getStatus();
30        if ($newStatus > $currentStatus) {
31            $this->markPolicySnapshotFrozen($file, $newStatus);
32            $file->setStatus($newStatus);
33            $this->touchStatusChangedAt($file);
34            $this->fileMapper->update($file);
35            $this->statusCacheService->setStatus($file->getUuid(), $newStatus);
36
37            if ($file->hasParent()) {
38                $this->propagateStatusToParent($file->getParentFileId());
39            }
40        }
41
42        return $file;
43    }
44
45    public function invalidateCachedStatus(FileEntity $file): void {
46        $this->statusCacheService->invalidateStatus($file->getUuid());
47    }
48
49    public function canNotifySigners(?int $fileStatus): bool {
50        return $fileStatus === FileStatus::ABLE_TO_SIGN->value;
51    }
52
53    public function propagateStatusToParent(int $parentId): void {
54        try {
55            $parent = $this->fileMapper->getById($parentId);
56        } catch (DoesNotExistException) {
57            return;
58        }
59
60        if (!$parent->isEnvelope()) {
61            return;
62        }
63
64        $children = $this->fileMapper->getChildrenFiles($parentId);
65
66        if (empty($children)) {
67            return;
68        }
69
70        // A canceled document is over without a signature: the envelope follows the
71        // documents that are left, and is canceled only when none is left.
72        $children = array_filter(
73            $children,
74            static fn (FileEntity $child): bool => $child->getStatus() !== FileStatus::CANCELED->value,
75        );
76
77        $minStatus = FileStatus::SIGNED->value;
78        $maxStatus = FileStatus::DRAFT->value;
79
80        foreach ($children as $child) {
81            $status = $child->getStatus();
82            if ($status < $minStatus) {
83                $minStatus = $status;
84            }
85            if ($status > $maxStatus) {
86                $maxStatus = $status;
87            }
88        }
89
90        $newStatus = FileStatus::DRAFT->value;
91
92        if ($children === []) {
93            $newStatus = FileStatus::CANCELED->value;
94        } elseif ($minStatus === FileStatus::SIGNED->value && $maxStatus === FileStatus::SIGNED->value) {
95            $newStatus = FileStatus::SIGNED->value;
96        } elseif ($maxStatus >= FileStatus::PARTIAL_SIGNED->value) {
97            $newStatus = FileStatus::PARTIAL_SIGNED->value;
98        } elseif ($maxStatus >= FileStatus::ABLE_TO_SIGN->value) {
99            $newStatus = FileStatus::ABLE_TO_SIGN->value;
100        }
101
102        if ($parent->getStatus() !== $newStatus) {
103            $this->markPolicySnapshotFrozen($parent, $newStatus);
104            $parent->setStatus($newStatus);
105            $this->touchStatusChangedAt($parent);
106            $this->fileMapper->update($parent);
107            $this->statusCacheService->setStatus($parent->getUuid(), $newStatus);
108        }
109    }
110
111    /** Persist enqueue status inside the caller's database transaction. */
112    public function updateForQueuedJob(FileEntity $file): FileEntity {
113        $this->touchStatusChangedAt($file);
114        $this->fileMapper->updateWithoutCache($file);
115        return $file;
116    }
117
118    /** Restore the local entity after rollback; do not write to the database again. */
119    public function discardQueuedJobStatus(FileEntity $file, int $previousStatus, ?array $previousMetadata): void {
120        $file->setStatus($previousStatus);
121        $file->setMetadata($previousMetadata ?? []);
122        $this->fileMapper->invalidateEntityCache($file);
123    }
124
125    /**
126     * Invalidate enqueue-era hints after commit, rather than publishing an
127     * in-progress snapshot that might already have been superseded by a worker.
128     */
129    public function onQueuedJobCommitted(FileEntity $file): void {
130        $this->fileMapper->invalidateEntityCache($file);
131        $this->statusCacheService->invalidateStatus($file->getUuid());
132        if ($file->hasParent()) {
133            $this->propagateStatusToParent($file->getParentFileId());
134        }
135    }
136
137    public function update(FileEntity $file): FileEntity {
138        $this->touchStatusChangedAt($file);
139        $this->fileMapper->update($file);
140        $this->statusCacheService->setStatus($file->getUuid(), $file->getStatus());
141
142        if ($file->hasParent()) {
143            $this->propagateStatusToParent($file->getParentFileId());
144        }
145
146        return $file;
147    }
148
149    public function propagateStatusToChildren(int $envelopeId, int $newStatus): void {
150        try {
151            $envelope = $this->fileMapper->getById($envelopeId);
152        } catch (DoesNotExistException) {
153            return;
154        }
155
156        if (!$envelope->isEnvelope()) {
157            return;
158        }
159
160        $children = $this->fileMapper->getChildrenFiles($envelopeId);
161
162        foreach ($children as $child) {
163            if ($child->getStatus() !== $newStatus) {
164                $this->markPolicySnapshotFrozen($child, $newStatus);
165                $child->setStatus($newStatus);
166                $this->touchStatusChangedAt($child);
167                $this->fileMapper->update($child);
168                $this->statusCacheService->setStatus($child->getUuid(), $newStatus);
169            }
170        }
171    }
172
173    /**
174     * The policy snapshot of a request freezes the first time it enters the
175     * signing flow and stays frozen if it returns to DRAFT. Call it before the
176     * status changes, so a request sent before the freeze was recorded keeps it
177     * when it goes back to DRAFT.
178     */
179    public function markPolicySnapshotFrozen(FileEntity $file, int $newStatus): void {
180        if (max($file->getStatus(), $newStatus) < FileStatus::ABLE_TO_SIGN->value) {
181            return;
182        }
183
184        $metadata = $file->getMetadata() ?? [];
185        if (isset($metadata[self::POLICY_SNAPSHOT_FROZEN_AT])) {
186            return;
187        }
188
189        $metadata[self::POLICY_SNAPSHOT_FROZEN_AT] = (new DateTime())->format(DateTimeInterface::ATOM);
190        $file->setMetadata($metadata);
191    }
192
193    /**
194     * A document freezes with its envelope, so pass the envelope of a document
195     * that has one.
196     */
197    public static function isPolicySnapshotFrozen(FileEntity $file, ?FileEntity $envelope = null): bool {
198        return self::hasFrozenPolicySnapshot($file)
199            || ($envelope !== null && self::hasFrozenPolicySnapshot($envelope));
200    }
201
202    /**
203     * A request sent before the freeze was recorded has no marker, so it is
204     * frozen while its status says it was sent.
205     */
206    private static function hasFrozenPolicySnapshot(FileEntity $file): bool {
207        return isset(($file->getMetadata() ?? [])[self::POLICY_SNAPSHOT_FROZEN_AT])
208            || $file->getStatus() >= FileStatus::ABLE_TO_SIGN->value;
209    }
210
211    private function touchStatusChangedAt(FileEntity $file): void {
212        $metadata = $file->getMetadata() ?? [];
213        $metadata['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
214        $file->setMetadata($metadata);
215    }
216}