Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.80% |
89 / 91 |
|
85.71% |
12 / 14 |
CRAP | |
0.00% |
0 / 1 |
| FileStatusService | |
97.80% |
89 / 91 |
|
85.71% |
12 / 14 |
39 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| updateFileStatusIfUpgrade | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| invalidateCachedStatus | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| canNotifySigners | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| propagateStatusToParent | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
13 | |||
| updateForQueuedJob | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| discardQueuedJobStatus | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| onQueuedJobCommitted | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| update | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| propagateStatusToChildren | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| markPolicySnapshotFrozen | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| isPolicySnapshotFrozen | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| hasFrozenPolicySnapshot | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| touchStatusChangedAt | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Service; |
| 10 | |
| 11 | use DateTime; |
| 12 | use DateTimeInterface; |
| 13 | use OCA\Libresign\Db\File as FileEntity; |
| 14 | use OCA\Libresign\Db\FileMapper; |
| 15 | use OCA\Libresign\Enum\FileStatus; |
| 16 | use OCA\Libresign\Service\SignRequest\StatusCacheService; |
| 17 | use OCP\AppFramework\Db\DoesNotExistException; |
| 18 | |
| 19 | class 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 | } |