• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 20907588007

12 Jan 2026 03:57AM UTC coverage: 43.867%. First build
20907588007

Pull #6436

github

web-flow
Merge 9c5490a63 into 8fe916f99
Pull Request #6436: feat: async parallel signing

242 of 775 new or added lines in 26 files covered. (31.23%)

6920 of 15775 relevant lines covered (43.87%)

4.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/lib/Controller/FileProgressController.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Controller;
10

11
use OCA\Libresign\Db\File as FileEntity;
12
use OCA\Libresign\Db\FileMapper;
13
use OCA\Libresign\Db\SignRequestMapper;
14
use OCA\Libresign\Enum\FileStatus;
15
use OCA\Libresign\Service\WorkerHealthService;
16
use OCP\AppFramework\Http;
17
use OCP\AppFramework\Http\Attribute\ApiRoute;
18
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
19
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
20
use OCP\AppFramework\Http\DataResponse;
21
use OCP\AppFramework\OCSController;
22
use OCP\ICache;
23
use OCP\ICacheFactory;
24
use OCP\IRequest;
25
use Psr\Log\LoggerInterface;
26

27
class FileProgressController extends OCSController {
28
        private ICache $cache;
29

30
        public function __construct(
31
                string $appName,
32
                IRequest $request,
33
                private FileMapper $fileMapper,
34
                private SignRequestMapper $signRequestMapper,
35
                private WorkerHealthService $workerHealthService,
36
                private LoggerInterface $logger,
37
                ICacheFactory $cacheFactory,
38
        ) {
NEW
39
                parent::__construct($appName, $request);
×
NEW
40
                $this->cache = $cacheFactory->createDistributed('libresign_progress');
×
41
        }
42

43
        /**
44
         * Wait for file/envelope status changes (long polling)
45
         *
46
         * Keeps connection open for up to 30 seconds waiting for status change.
47
         *
48
         * @param int $fileId LibreSign file ID
49
         * @param int $currentStatus Current status known by client
50
         * @param int $timeout Seconds to wait (default 30, max 30)
51
         * @return DataResponse<Http::STATUS_OK, array{status: int, statusText: string, name: string, progress: array<string, mixed>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
52
         *
53
         * 200: Status and progress returned
54
         * 404: File not found
55
         */
56
        #[NoAdminRequired]
57
        #[NoCSRFRequired]
58
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/{fileId}/wait-status', requirements: ['apiVersion' => '(v1)'])]
59
        public function waitForStatusChange(
60
                int $fileId,
61
                int $currentStatus,
62
                int $timeout = 30,
63
        ): DataResponse {
NEW
64
                $timeout = min(30, $timeout);
×
NEW
65
                $elapsedTime = 0;
×
66

67
                try {
68
                        // Initial check
NEW
69
                        $file = $this->fileMapper->getById($fileId);
×
NEW
70
                        $statusChanged = $file->getStatus() !== $currentStatus;
×
71

72
                        // Long polling loop
NEW
73
                        while (!$statusChanged && $elapsedTime < $timeout) {
×
NEW
74
                                sleep(1);
×
NEW
75
                                $elapsedTime++;
×
76

77
                                // Re-fetch from DB
NEW
78
                                $file = $this->fileMapper->getById($fileId);
×
NEW
79
                                $statusChanged = $file->getStatus() !== $currentStatus;
×
80
                        }
81

82
                        // Build response with progress data
NEW
83
                        return new DataResponse([
×
NEW
84
                                'status' => $file->getStatus(),
×
NEW
85
                                'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()),
×
NEW
86
                                'name' => $file->getName(),
×
NEW
87
                                'progress' => $this->getSigningProgress($file),
×
NEW
88
                        ], Http::STATUS_OK);
×
89

NEW
90
                } catch (\Exception $e) {
×
NEW
91
                        return new DataResponse([
×
NEW
92
                                'message' => $e->getMessage(),
×
NEW
93
                        ], Http::STATUS_NOT_FOUND);
×
94
                }
95
        }
96

97
        /**
98
         * Check file progress by UUID with long-polling (similar to Talk)
99
         *
100
         * Waits up to 30 seconds for status change using cache for efficiency.
101
         *
102
         * @param string $uuid File UUID
103
         * @param int $timeout Maximum seconds to wait (default 30)
104
         * @return DataResponse<Http::STATUS_OK, array{status: string, statusCode: int, statusText: string, fileId: int, progress: array<string, mixed>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string, status: string}, array{}>
105
         *
106
         * 200: Status and progress returned
107
         * 404: File not found
108
         */
109
        #[NoAdminRequired]
110
        #[NoCSRFRequired]
111
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/progress/{uuid}', requirements: ['apiVersion' => '(v1)'])]
112
        public function checkProgressByUuid(string $uuid, int $timeout = 30): DataResponse {
113
                try {
114
                        // Get file by UUID
NEW
115
                        $file = $this->fileMapper->getByUuid($uuid);
×
NEW
116
                        $currentStatus = $file->getStatus();
×
NEW
117
                        $progress = $this->getSigningProgress($file);
×
118

119
                        // If we're in async progress status, ensure a worker is running
NEW
120
                        if ($currentStatus === FileStatus::SIGNING_IN_PROGRESS->value) {
×
NEW
121
                                $this->workerHealthService->ensureWorkerRunning();
×
122
                        }
123

NEW
124
                        $statusEnum = FileStatus::tryFrom($currentStatus);
×
NEW
125
                        $statusText = $statusEnum?->name ?? 'UNKNOWN';
×
126

127
                        // If already in final state, return immediately
NEW
128
                        if ($currentStatus === FileStatus::SIGNED->value
×
NEW
129
                                || $currentStatus === FileStatus::DELETED->value) {
×
NEW
130
                                return new DataResponse([
×
NEW
131
                                        'status' => $statusText,
×
NEW
132
                                        'statusCode' => $currentStatus,
×
NEW
133
                                        'statusText' => $this->fileMapper->getTextOfStatus($currentStatus),
×
NEW
134
                                        'fileId' => $file->getId(),
×
NEW
135
                                        'progress' => $progress,
×
NEW
136
                                ], Http::STATUS_OK);
×
137
                        }
138

139
                        // If not in signing state and not final, return without polling
NEW
140
                        if ($currentStatus !== FileStatus::SIGNING_IN_PROGRESS->value) {
×
NEW
141
                                return new DataResponse([
×
NEW
142
                                        'status' => $statusText,
×
NEW
143
                                        'statusCode' => $currentStatus,
×
NEW
144
                                        'statusText' => $this->fileMapper->getTextOfStatus($currentStatus),
×
NEW
145
                                        'fileId' => $file->getId(),
×
NEW
146
                                        'progress' => $progress,
×
NEW
147
                                ], Http::STATUS_OK);
×
148
                        }
149

150
                        // Only perform long-polling if currently SIGNING_IN_PROGRESS
NEW
151
                        $elapsedTime = 0;
×
NEW
152
                        $cacheKey = 'status_' . $uuid;
×
153

154
                        // Get initial cached status
NEW
155
                        $cachedStatus = $this->cache->get($cacheKey);
×
156

NEW
157
                        while ($elapsedTime < $timeout) {
×
158
                                // Check cache first (like Talk's checkCacheOrDatabase)
NEW
159
                                $newCachedStatus = $this->cache->get($cacheKey);
×
160

NEW
161
                                if ($newCachedStatus !== $cachedStatus && $newCachedStatus !== false) {
×
162
                                        // Cache changed - trust cache immediately to avoid DB lag
NEW
163
                                        $currentStatus = (int)$newCachedStatus;
×
NEW
164
                                        $statusEnum = FileStatus::tryFrom($currentStatus);
×
NEW
165
                                        $statusText = $statusEnum?->name ?? 'UNKNOWN';
×
NEW
166
                                        break;
×
167
                                }
168

169
                                // No cache change yet - wait 1 second
NEW
170
                                sleep(1);
×
NEW
171
                                $elapsedTime++;
×
172
                        }
173

NEW
174
                        return new DataResponse([
×
NEW
175
                                'status' => $statusText,
×
NEW
176
                                'statusCode' => $currentStatus,
×
NEW
177
                                'statusText' => $this->fileMapper->getTextOfStatus($currentStatus),
×
NEW
178
                                'fileId' => $file->getId(),
×
NEW
179
                                'progress' => $this->getSigningProgress($file),
×
NEW
180
                        ], Http::STATUS_OK);
×
181

NEW
182
                } catch (\Exception $e) {
×
NEW
183
                        return new DataResponse([
×
NEW
184
                                'message' => $e->getMessage(),
×
NEW
185
                                'status' => 'ERROR',
×
NEW
186
                        ], Http::STATUS_NOT_FOUND);
×
187
                }
188
        }
189

190
        /**
191
         * Get signing progress for file or envelope
192
         */
193
        private function getSigningProgress(FileEntity $file): array {
NEW
194
                if ($file->getNodeType() === 'envelope') {
×
NEW
195
                        return $this->getEnvelopeProgress($file);
×
196
                }
197

198
                // For single files (no parent, not envelope), display as single-file progress
199
                // This covers async signing without relying on status checks which may lag
NEW
200
                if (!$file->getParentFileId()) {
×
NEW
201
                        return $this->getSingleFileProgress($file);
×
202
                }
203

NEW
204
                return $this->getFileProgress($file);
×
205
        }
206

207
        /**
208
         * Get progress for a single file (not an envelope, not a child)
209
         * Returns file-list format for consistent UI display
210
         */
211
        private function getSingleFileProgress(FileEntity $file): array {
NEW
212
                return [
×
NEW
213
                        'total' => 1,
×
NEW
214
                        'signed' => $file->getStatus() === FileStatus::SIGNED->value ? 1 : 0,
×
NEW
215
                        'inProgress' => $file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value ? 1 : 0,
×
NEW
216
                        'pending' => $file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value ? 0 : ($file->getStatus() === FileStatus::SIGNED->value ? 0 : 1),
×
NEW
217
                        'files' => [
×
NEW
218
                                [
×
NEW
219
                                        'id' => $file->getId(),
×
NEW
220
                                        'name' => $file->getName(),
×
NEW
221
                                        'status' => $file->getStatus(),
×
NEW
222
                                        'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()),
×
NEW
223
                                ]
×
NEW
224
                        ],
×
NEW
225
                ];
×
226
        }
227

228
        private function getEnvelopeProgress(FileEntity $envelope): array {
NEW
229
                $children = $this->fileMapper->getChildrenFiles($envelope->getId());
×
230

231
                // If no children, this is an async-signing single file marked as envelope
232
                // Include the envelope itself in the progress display
NEW
233
                if (empty($children)) {
×
NEW
234
                        $children = [$envelope];
×
235
                }
236

NEW
237
                $total = count($children);
×
NEW
238
                $signed = 0;
×
NEW
239
                $inProgress = 0;
×
NEW
240
                $pending = 0;
×
241

NEW
242
                $files = [];
×
NEW
243
                foreach ($children as $child) {
×
NEW
244
                        $childStatus = $child->getStatus();
×
NEW
245
                        if ($childStatus === FileStatus::SIGNED->value) {
×
NEW
246
                                $signed++;
×
NEW
247
                        } elseif ($childStatus === FileStatus::SIGNING_IN_PROGRESS->value) {
×
NEW
248
                                $inProgress++;
×
249
                        } else {
250
                                // Any status other than SIGNED or SIGNING_IN_PROGRESS is pending
251
                                // This includes: DRAFT, ABLE_TO_SIGN, PARTIAL_SIGNED, etc.
NEW
252
                                $pending++;
×
253
                        }
254

NEW
255
                        $files[] = [
×
NEW
256
                                'id' => $child->getId(),
×
NEW
257
                                'name' => $child->getName(),
×
NEW
258
                                'status' => $childStatus,
×
NEW
259
                                'statusText' => $this->fileMapper->getTextOfStatus($childStatus),
×
NEW
260
                        ];
×
261
                }
262

NEW
263
                return [
×
NEW
264
                        'total' => $total,
×
NEW
265
                        'signed' => $signed,
×
NEW
266
                        'inProgress' => $inProgress,
×
NEW
267
                        'pending' => $pending,
×
NEW
268
                        'files' => $files,
×
NEW
269
                ];
×
270
        }
271

272
        private function getFileProgress(FileEntity $file): array {
NEW
273
                $signRequests = $this->signRequestMapper->getByFileId($file->getId());
×
274

NEW
275
                $total = count($signRequests);
×
NEW
276
                $signed = count(array_filter($signRequests, fn ($sr) => $sr->getSigned() !== null));
×
277

NEW
278
                return [
×
NEW
279
                        'total' => $total,
×
NEW
280
                        'signed' => $signed,
×
NEW
281
                        'pending' => $total - $signed,
×
NEW
282
                        'signers' => array_map(function ($sr) {
×
NEW
283
                                return [
×
NEW
284
                                        'id' => $sr->getId(),
×
NEW
285
                                        'displayName' => $sr->getDisplayName(),
×
NEW
286
                                        'signed' => $sr->getSigned() ? $sr->getSigned()->format('c') : null,
×
NEW
287
                                        'status' => $sr->getStatus(),
×
NEW
288
                                ];
×
NEW
289
                        }, $signRequests),
×
NEW
290
                ];
×
291
        }
292
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc