From 20b84e071c094f6d833af916aa4dd1630eb63ad1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:57:47 -0300 Subject: [PATCH 01/34] feat: add error reporting infrastructure for async signing Add StatusCacheService for caching file status during async operations. Add SignRequestErrorReporter for storing and retrieving per-file errors. Add ErrorPayloadBuilder for consistent error payloads. These services enable granular error tracking during background signing jobs and provide detailed feedback to users about which specific files failed and why. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignRequest/Error/ErrorPayloadBuilder.php | 96 +++++++++++++++++++ .../Error/SignRequestErrorReporter.php | 59 ++++++++++++ .../SignRequest/StatusCacheService.php | 37 +++++++ 3 files changed, 192 insertions(+) create mode 100644 lib/Service/SignRequest/Error/ErrorPayloadBuilder.php create mode 100644 lib/Service/SignRequest/Error/SignRequestErrorReporter.php create mode 100644 lib/Service/SignRequest/StatusCacheService.php diff --git a/lib/Service/SignRequest/Error/ErrorPayloadBuilder.php b/lib/Service/SignRequest/Error/ErrorPayloadBuilder.php new file mode 100644 index 0000000000..92e635d3c8 --- /dev/null +++ b/lib/Service/SignRequest/Error/ErrorPayloadBuilder.php @@ -0,0 +1,96 @@ + */ + private array $fileErrors = []; + + public static function fromException(\Throwable $e, ?int $fileId = null, ?int $signRequestId = null, ?string $signRequestUuid = null): self { + $builder = new self(); + return $builder + ->setMessage($e->getMessage()) + ->setCode($e->getCode()) + ->setFileId($fileId) + ->setSignRequestId($signRequestId) + ->setSignRequestUuid($signRequestUuid); + } + + public function setMessage(string $message): self { + $this->message = $message; + return $this; + } + + public function setCode(int $code): self { + $this->code = $code; + return $this; + } + + public function setFileId(?int $fileId): self { + $this->fileId = $fileId; + return $this; + } + + public function setSignRequestId(?int $signRequestId): self { + $this->signRequestId = $signRequestId; + return $this; + } + + public function setSignRequestUuid(?string $signRequestUuid): self { + $this->signRequestUuid = $signRequestUuid; + return $this; + } + + public function addFileError(int $fileId, \Throwable $e): self { + $this->fileErrors[$fileId] = [ + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]; + return $this; + } + + public function clearFileErrors(): self { + $this->fileErrors = []; + return $this; + } + + public function build(): array { + $payload = [ + 'message' => $this->message, + 'code' => $this->code, + 'timestamp' => (new DateTime())->format(DateTimeInterface::ATOM), + ]; + + if ($this->fileId !== null) { + $payload['fileId'] = $this->fileId; + } + + if ($this->signRequestId !== null) { + $payload['signRequestId'] = $this->signRequestId; + } + + if ($this->signRequestUuid !== null) { + $payload['signRequestUuid'] = $this->signRequestUuid; + } + + if (!empty($this->fileErrors)) { + $payload['fileErrors'] = $this->fileErrors; + } + + return $payload; + } +} diff --git a/lib/Service/SignRequest/Error/SignRequestErrorReporter.php b/lib/Service/SignRequest/Error/SignRequestErrorReporter.php new file mode 100644 index 0000000000..d7bbe8141f --- /dev/null +++ b/lib/Service/SignRequest/Error/SignRequestErrorReporter.php @@ -0,0 +1,59 @@ +maybeStoreProgressError($level, $context); + $this->logger->log($level, $message, $context); + } + + private function maybeStoreProgressError(string $level, array $context): void { + if ($level !== LogLevel::ERROR) { + return; + } + + $exception = $context['exception'] ?? null; + if (!$exception instanceof \Throwable) { + return; + } + + $signRequestUuid = $context['signRequestUuid'] ?? null; + if (empty($signRequestUuid)) { + return; + } + + $ttl = (int)($context['ttl'] ?? 300); + $fileId = $context['fileId'] ?? null; + $signRequestId = $context['signRequestId'] ?? null; + $payload = ErrorPayloadBuilder::fromException( + e: $exception, + fileId: is_numeric($fileId) ? (int)$fileId : null, + signRequestId: is_numeric($signRequestId) ? (int)$signRequestId : null, + signRequestUuid: $signRequestUuid + )->build(); + + $this->progressService->setSignRequestError($signRequestUuid, $payload, $ttl); + if (is_numeric($fileId)) { + $this->progressService->setFileError($signRequestUuid, (int)$fileId, $payload, $ttl); + } + } +} diff --git a/lib/Service/SignRequest/StatusCacheService.php b/lib/Service/SignRequest/StatusCacheService.php new file mode 100644 index 0000000000..b3f72fa16f --- /dev/null +++ b/lib/Service/SignRequest/StatusCacheService.php @@ -0,0 +1,37 @@ +cache = $cacheFactory->createDistributed('libresign_progress'); + } + + public function setStatus(string $fileUuid, int $status, int $ttl = self::DEFAULT_TTL): void { + if ($fileUuid === '') { + return; + } + $this->cache->set(self::STATUS_KEY_PREFIX . $fileUuid, $status, $ttl); + } + + public function getStatus(string $fileUuid): mixed { + if ($fileUuid === '') { + return false; + } + return $this->cache->get(self::STATUS_KEY_PREFIX . $fileUuid); + } +} From 598f90ac76e0b0b84b89e0e5e08f226e7d4322fa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:57:55 -0300 Subject: [PATCH 02/34] feat: extend ProgressService with per-file error tracking Add methods to set/get/clear errors for individual files and sign requests. Implement long-polling with error detection across envelope children. Add fallback to metadata when cache is not available. This allows the frontend to display specific error messages for each file in a multi-file signing workflow, improving user feedback. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignRequest/ProgressService.php | 367 ++++++++++++++++++-- 1 file changed, 347 insertions(+), 20 deletions(-) diff --git a/lib/Service/SignRequest/ProgressService.php b/lib/Service/SignRequest/ProgressService.php index cf71f3013b..055501dfdb 100644 --- a/lib/Service/SignRequest/ProgressService.php +++ b/lib/Service/SignRequest/ProgressService.php @@ -11,8 +11,10 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequest as SignRequestEntity; +use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\SignRequestStatus; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\ICache; use OCP\ICacheFactory; @@ -29,10 +31,18 @@ */ class ProgressService { private ICache $cache; + public const ERROR_KEY_PREFIX = 'libresign_sign_request_error_'; + public const FILE_ERROR_KEY_PREFIX = 'libresign_file_error_'; + /** @var array> */ + private array $signRequestErrors = []; + /** @var array> */ + private array $fileErrors = []; public function __construct( private FileMapper $fileMapper, ICacheFactory $cacheFactory, + private SignRequestMapper $signRequestMapper, + private StatusCacheService $statusCacheService, ) { $this->cache = $cacheFactory->createDistributed('libresign_progress'); } @@ -45,13 +55,62 @@ public function __construct( * @return int The current status (changed or original if timeout reached) */ public function pollForStatusChange(string $uuid, int $initialStatus, int $timeout = 30, int $intervalSeconds = 1): int { - $cacheKey = 'status_' . $uuid; - $cachedStatus = $this->cache->get($cacheKey); + return $this->pollForStatusChangeInternal($uuid, [], $initialStatus, $timeout, $intervalSeconds); + } + + public function pollForStatusOrErrorChange( + FileEntity $file, + SignRequestEntity $signRequest, + int $initialStatus, + int $timeout = 30, + int $intervalSeconds = 1, + ): int { + $statusUuid = $file->getUuid(); + if ($file->getNodeType() !== 'envelope') { + return $this->pollForStatusChangeInternal( + $statusUuid, + [$signRequest->getUuid()], + $initialStatus, + $timeout, + $intervalSeconds, + ); + } + + $signRequestUuids = [$signRequest->getUuid()]; + $childSignRequests = $this->signRequestMapper + ->getByEnvelopeChildrenAndIdentifyMethod($file->getId(), $signRequest->getId()); + foreach ($childSignRequests as $childSignRequest) { + $childUuid = $childSignRequest->getUuid(); + if ($childUuid !== '') { + $signRequestUuids[] = $childUuid; + } + } + + return $this->pollForStatusChangeInternal( + $statusUuid, + $signRequestUuids, + $initialStatus, + $timeout, + $intervalSeconds, + ); + } + + private function pollForStatusChangeInternal( + string $statusUuid, + array $errorUuids, + int $initialStatus, + int $timeout, + int $intervalSeconds, + ): int { + $cachedStatus = $this->statusCacheService->getStatus($statusUuid); $interval = max(1, $intervalSeconds); for ($elapsed = 0; $elapsed < $timeout; $elapsed += $interval) { - $newCachedStatus = $this->cache->get($cacheKey); + if (!empty($errorUuids) && $this->hasAnySignRequestError($errorUuids)) { + return $initialStatus; + } + $newCachedStatus = $this->statusCacheService->getStatus($statusUuid); if ($newCachedStatus !== $cachedStatus && $newCachedStatus !== false) { return (int)$newCachedStatus; } @@ -64,6 +123,213 @@ public function pollForStatusChange(string $uuid, int $initialStatus, int $timeo return $initialStatus; } + public function setSignRequestError(string $uuid, array $error, int $ttl = 300): void { + $this->signRequestErrors[$uuid] = $error; + $this->cache->set(self::ERROR_KEY_PREFIX . $uuid, $error, $ttl); + $this->storeSignRequestErrorInMetadata($uuid, $error); + } + + + public function getSignRequestError(string $uuid): ?array { + $error = $this->cache->get(self::ERROR_KEY_PREFIX . $uuid); + if ($error === false || $error === null) { + return $this->signRequestErrors[$uuid] + ?? $this->getSignRequestErrorFromMetadata($uuid); + } + return is_array($error) ? $error : ['message' => (string)$error]; + } + + public function clearSignRequestError(string $uuid): void { + unset($this->signRequestErrors[$uuid]); + $this->cache->remove(self::ERROR_KEY_PREFIX . $uuid); + $this->clearSignRequestErrorInMetadata($uuid); + } + + private function hasSignRequestError(string $uuid): bool { + $error = $this->getSignRequestError($uuid); + return $error !== null; + } + + private function hasAnySignRequestError(array $uuids): bool { + foreach ($uuids as $uuid) { + if ($uuid !== '' && $this->hasSignRequestError($uuid)) { + return true; + } + } + return false; + } + + public function setFileError(string $uuid, int $fileId, array $error, int $ttl = 300): void { + $key = $this->buildFileErrorKey($uuid, $fileId); + $this->fileErrors[$key] = $error; + $this->cache->set($key, $error, $ttl); + $this->storeFileErrorInMetadata($uuid, $fileId, $error); + } + + public function getFileError(string $uuid, int $fileId): ?array { + $key = $this->buildFileErrorKey($uuid, $fileId); + $error = $this->cache->get($key); + if ($error === false || $error === null) { + return $this->fileErrors[$key] + ?? $this->getFileErrorFromMetadata($uuid, $fileId); + } + return is_array($error) ? $error : ['message' => (string)$error]; + } + + public function clearFileError(string $uuid, int $fileId): void { + $key = $this->buildFileErrorKey($uuid, $fileId); + unset($this->fileErrors[$key]); + $this->cache->remove($key); + $this->clearFileErrorInMetadata($uuid, $fileId); + } + + private function buildFileErrorKey(string $uuid, int $fileId): string { + return self::FILE_ERROR_KEY_PREFIX . $uuid . '_' . $fileId; + } + + private function storeSignRequestErrorInMetadata(string $uuid, array $error): void { + if ($uuid === '') { + return; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return; + } + if (!$signRequest instanceof SignRequestEntity) { + return; + } + + $metadata = $signRequest->getMetadata() ?? []; + $metadata['libresign_error'] = $error; + $signRequest->setMetadata($metadata); + $this->signRequestMapper->update($signRequest); + } + + private function getSignRequestErrorFromMetadata(string $uuid): ?array { + if ($uuid === '') { + return null; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return null; + } + if (!$signRequest instanceof SignRequestEntity) { + return null; + } + + $metadata = $signRequest->getMetadata() ?? []; + $error = $metadata['libresign_error'] ?? null; + return is_array($error) ? $error : null; + } + + private function clearSignRequestErrorInMetadata(string $uuid): void { + if ($uuid === '') { + return; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return; + } + if (!$signRequest instanceof SignRequestEntity) { + return; + } + + $metadata = $signRequest->getMetadata() ?? []; + if (!array_key_exists('libresign_error', $metadata)) { + return; + } + + unset($metadata['libresign_error']); + $signRequest->setMetadata($metadata); + $this->signRequestMapper->update($signRequest); + } + + private function storeFileErrorInMetadata(string $uuid, int $fileId, array $error): void { + if ($uuid === '') { + return; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return; + } + if (!$signRequest instanceof SignRequestEntity) { + return; + } + + $metadata = $signRequest->getMetadata() ?? []; + $fileErrors = $metadata['libresign_file_errors'] ?? []; + if (!is_array($fileErrors)) { + $fileErrors = []; + } + + $fileErrors[$fileId] = $error; + $metadata['libresign_file_errors'] = $fileErrors; + $signRequest->setMetadata($metadata); + $this->signRequestMapper->update($signRequest); + } + + private function getFileErrorFromMetadata(string $uuid, int $fileId): ?array { + if ($uuid === '') { + return null; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return null; + } + if (!$signRequest instanceof SignRequestEntity) { + return null; + } + + $metadata = $signRequest->getMetadata() ?? []; + $fileErrors = $metadata['libresign_file_errors'] ?? null; + if (!is_array($fileErrors)) { + return null; + } + + $error = $fileErrors[$fileId] ?? null; + return is_array($error) ? $error : null; + } + + private function clearFileErrorInMetadata(string $uuid, int $fileId): void { + if ($uuid === '') { + return; + } + + try { + $signRequest = $this->signRequestMapper->getByUuidUncached($uuid); + } catch (DoesNotExistException) { + return; + } + if (!$signRequest instanceof SignRequestEntity) { + return; + } + + $metadata = $signRequest->getMetadata() ?? []; + $fileErrors = $metadata['libresign_file_errors'] ?? null; + if (!is_array($fileErrors) || !array_key_exists($fileId, $fileErrors)) { + return; + } + + unset($fileErrors[$fileId]); + if (empty($fileErrors)) { + unset($metadata['libresign_file_errors']); + } else { + $metadata['libresign_file_errors'] = $fileErrors; + } + $signRequest->setMetadata($metadata); + $this->signRequestMapper->update($signRequest); + } + /** * Get progress for a specific sign request * @@ -92,7 +358,8 @@ public function isProgressComplete(array $progress): bool { $signed = (int)($progress['signed'] ?? 0); $pending = (int)($progress['pending'] ?? 0); $inProgress = (int)($progress['inProgress'] ?? 0); - return $signed >= $total && $pending <= 0 && $inProgress <= 0; + $errors = (int)($progress['errors'] ?? 0); + return ($signed + $errors) >= $total && $pending <= 0 && $inProgress <= 0; } /** @@ -106,19 +373,25 @@ public function getSingleFileProgressForSignRequest(FileEntity $file, SignReques $statusCode = $this->getSignRequestStatusCode($file, $signRequest); $isSigned = $statusCode === FileStatus::SIGNED->value; $isInProgress = $statusCode === FileStatus::SIGNING_IN_PROGRESS->value; + $fileError = $this->getFileError($signRequest->getUuid(), $file->getId()); + $hasError = $fileError !== null; return [ 'total' => 1, 'signed' => $isSigned ? 1 : 0, - 'inProgress' => $isInProgress ? 1 : 0, - 'pending' => $isSigned || $isInProgress ? 0 : 1, + 'inProgress' => $hasError ? 0 : ($isInProgress ? 1 : 0), + 'errors' => $hasError ? 1 : 0, + 'pending' => $hasError || $isSigned || $isInProgress ? 0 : 1, 'files' => [ - [ - 'id' => $file->getId(), - 'name' => $file->getName(), - 'status' => $statusCode, - 'statusText' => $this->fileMapper->getTextOfStatus($statusCode), - ] + array_merge( + [ + 'id' => $file->getId(), + 'name' => $file->getName(), + 'status' => $statusCode, + 'statusText' => $this->fileMapper->getTextOfStatus($statusCode), + ], + $hasError ? ['error' => $fileError] : [] + ) ], ]; } @@ -136,19 +409,28 @@ public function getEnvelopeProgressForSignRequest(FileEntity $envelope, SignRequ $children = [$envelope]; } - $files = array_map( - fn ($child) => $this->mapSignRequestFileProgress($child, $signRequest), - $children - ); + $childSignRequests = $this->signRequestMapper + ->getByEnvelopeChildrenAndIdentifyMethod($envelope->getId(), $signRequest->getId()); + $childSignRequestsByFileId = []; + foreach ($childSignRequests as $childSignRequest) { + $childSignRequestsByFileId[$childSignRequest->getFileId()] = $childSignRequest; + } + + $files = array_map(function (FileEntity $child) use ($signRequest, $childSignRequestsByFileId): array { + $childSignRequest = $childSignRequestsByFileId[$child->getId()] ?? null; + return $this->mapSignRequestFileProgressWithContext($child, $signRequest, $childSignRequest); + }, $children); $total = count($files); $signed = count(array_filter($files, fn (array $file) => $file['status'] === FileStatus::SIGNED->value)); $inProgress = count(array_filter($files, fn (array $file) => $file['status'] === FileStatus::SIGNING_IN_PROGRESS->value)); - $pending = max(0, $total - $signed - $inProgress); + $errors = count(array_filter($files, fn (array $file) => !empty($file['error']))); + $pending = max(0, $total - $signed - $inProgress - $errors); return [ 'total' => $total, 'signed' => $signed, 'inProgress' => $inProgress, + 'errors' => $errors, 'pending' => $pending, 'files' => $files, ]; @@ -163,12 +445,15 @@ public function getFileProgressForSignRequest(FileEntity $file, SignRequestEntit $statusCode = $this->getSignRequestStatusCode($file, $signRequest); $isSigned = $statusCode === FileStatus::SIGNED->value; $isInProgress = $statusCode === FileStatus::SIGNING_IN_PROGRESS->value; + $fileError = $this->getFileError($signRequest->getUuid(), $file->getId()); + $hasError = $fileError !== null; return [ 'total' => 1, 'signed' => $isSigned ? 1 : 0, - 'inProgress' => $isInProgress ? 1 : 0, - 'pending' => $isSigned || $isInProgress ? 0 : 1, + 'inProgress' => $hasError ? 0 : ($isInProgress ? 1 : 0), + 'errors' => $hasError ? 1 : 0, + 'pending' => $hasError || $isSigned || $isInProgress ? 0 : 1, 'signers' => [ [ 'id' => $signRequest->getId(), @@ -196,12 +481,54 @@ private function mapFileProgress(FileEntity $file): array { private function mapSignRequestFileProgress(FileEntity $file, SignRequestEntity $signRequest): array { $statusCode = $this->getSignRequestStatusCode($file, $signRequest); - return [ + $error = $this->getFileError($signRequest->getUuid(), $file->getId()); + + $mapped = [ + 'id' => $file->getId(), + 'name' => $file->getName(), + 'status' => $statusCode, + 'statusText' => $this->fileMapper->getTextOfStatus($statusCode), + ]; + + if ($error !== null) { + $mapped['error'] = $error; + } + + return $mapped; + } + + private function mapSignRequestFileProgressWithContext(FileEntity $file, SignRequestEntity $defaultSignRequest, ?SignRequestEntity $childSignRequest): array { + $effectiveSignRequest = $childSignRequest ?? $defaultSignRequest; + $statusCode = $this->getSignRequestStatusCode($file, $effectiveSignRequest); + $errorUuid = $childSignRequest?->getUuid() ?? $defaultSignRequest->getUuid(); + $error = $this->getFileError($errorUuid, $file->getId()); + if ($error === null) { + $error = $this->findFileErrorAcrossSignRequests($file->getId()); + } + + $mapped = [ 'id' => $file->getId(), 'name' => $file->getName(), 'status' => $statusCode, 'statusText' => $this->fileMapper->getTextOfStatus($statusCode), ]; + + if ($error !== null) { + $mapped['error'] = $error; + } + + return $mapped; + } + + private function findFileErrorAcrossSignRequests(int $fileId): ?array { + $signRequests = $this->signRequestMapper->getByFileId($fileId); + foreach ($signRequests as $signRequest) { + $error = $this->getFileError($signRequest->getUuid(), $fileId); + if ($error !== null) { + return $error; + } + } + return null; } private function getSignRequestStatusCode(FileEntity $file, SignRequestEntity $signRequest): int { From 66a50e4b05349e41ecc2600d8bb9bd6a0dbd983e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:03 -0300 Subject: [PATCH 03/34] refactor: inject StatusCacheService into StatusService Replace direct cache access with StatusCacheService dependency. Add getByEnvelopeChildrenAndIdentifyMethod to SignRequestMapper for querying child sign requests. This improves testability and centralizes status caching logic. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/SignRequestMapper.php | 16 ++++++++++++++++ lib/Service/SignRequest/StatusService.php | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/lib/Db/SignRequestMapper.php b/lib/Db/SignRequestMapper.php index 77e95c4072..678d267967 100644 --- a/lib/Db/SignRequestMapper.php +++ b/lib/Db/SignRequestMapper.php @@ -124,6 +124,22 @@ public function getByUuid(string $uuid): SignRequest { return $signRequest; } + /** + * @throws DoesNotExistException + */ + public function getByUuidUncached(string $uuid): SignRequest { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('uuid', $qb->createNamedParameter($uuid)) + ); + + /** @var SignRequest */ + return $this->findEntity($qb); + } + public function getByEmailAndFileId(string $email, int $fileId): SignRequest { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Service/SignRequest/StatusService.php b/lib/Service/SignRequest/StatusService.php index 83f6239c74..01ab09e446 100644 --- a/lib/Service/SignRequest/StatusService.php +++ b/lib/Service/SignRequest/StatusService.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Service\SignRequest; +use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\SignRequestStatus; @@ -18,6 +19,7 @@ class StatusService { public function __construct( private SequentialSigningService $sequentialSigningService, private FileStatusService $fileStatusService, + private StatusCacheService $statusCacheService, ) { } @@ -30,6 +32,10 @@ public function canNotifySignRequest(SignRequestStatus $status): bool { return $status === SignRequestStatus::ABLE_TO_SIGN; } + public function cacheFileStatus(FileEntity $file, int $ttl = StatusCacheService::DEFAULT_TTL): void { + $this->statusCacheService->setStatus($file->getUuid(), $file->getStatus(), $ttl); + } + public function updateStatusIfAllowed( SignRequestEntity $signRequest, SignRequestStatus $currentStatus, From ba962777cf57c997d91e7cb7ccb9b432bd3f25cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:09 -0300 Subject: [PATCH 04/34] feat: integrate error reporting in SignJobCoordinator Inject ProgressService and SignRequestErrorReporter. Clear errors before signing and report errors on exceptions. Preserve detailed error messages with HTTP status codes. Background jobs now store specific error information that the frontend can retrieve and display to users. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignJobCoordinator.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/Service/SignJobCoordinator.php b/lib/Service/SignJobCoordinator.php index 7f0d463204..0d507007ef 100644 --- a/lib/Service/SignJobCoordinator.php +++ b/lib/Service/SignJobCoordinator.php @@ -15,6 +15,8 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Service\SignRequest\Error\SignRequestErrorReporter; +use OCA\Libresign\Service\SignRequest\ProgressService; use OCP\IUser; use OCP\IUserManager; use OCP\Security\ICredentialsManager; @@ -28,11 +30,15 @@ public function __construct( private FolderService $folderService, private IUserManager $userManager, private ICredentialsManager $credentialsManager, + private ProgressService $progressService, + private SignRequestErrorReporter $signRequestErrorReporter, private LoggerInterface $logger, ) { } public function runSignFile(array $argument): void { + $file = null; + $signRequest = null; try { if (empty($argument)) { throw new \InvalidArgumentException('SignFileJob: Cannot proceed with empty arguments'); @@ -40,6 +46,7 @@ public function runSignFile(array $argument): void { [$fileId, $signRequestId] = $this->requireIds($argument, 'SignFileJob'); [$file, $signRequest] = $this->loadEntities($fileId, $signRequestId); + $this->progressService->clearSignRequestError($signRequest->getUuid()); $user = $this->resolveUser($argument['userId'] ?? null, null, null); if ($user) { $this->folderService->setUserId($user->getUID()); @@ -50,9 +57,11 @@ public function runSignFile(array $argument): void { $this->signFileService->sign(); } catch (\Throwable $e) { - $this->logger->error('SignFileJob failed: {error}', [ - 'error' => $e->getMessage(), + $this->signRequestErrorReporter->error('SignFileJob failed', [ 'exception' => $e, + 'fileId' => $file?->getId() ?? ($argument['fileId'] ?? null), + 'signRequestId' => $signRequest?->getId() ?? ($argument['signRequestId'] ?? null), + 'signRequestUuid' => $signRequest?->getUuid() ?? ($argument['signRequestUuid'] ?? null), ]); } finally { $this->deleteCredentials($argument['userId'] ?? '', $argument['credentialsId'] ?? null); @@ -73,7 +82,7 @@ public function runSignSingleFile(array $argument): void { [$fileId, $signRequestId] = $this->requireIds($argument, 'SignSingleFileJob'); [$file, $signRequest] = $this->loadEntities($fileId, $signRequestId); - + $this->progressService->clearSignRequestError($signRequest->getUuid()); $effectiveUserId = $effectiveUserId ?? $file->getUserId() @@ -90,11 +99,11 @@ public function runSignSingleFile(array $argument): void { $this->signFileService->signSingleFile($file, $signRequest); } catch (\Throwable $e) { - $contextFileId = $fileId ?? ($argument['fileId'] ?? 'unknown'); - $this->logger->error('SignSingleFileJob failed for file {fileId}: {error}', [ - 'fileId' => $contextFileId, - 'error' => $e->getMessage(), + $this->signRequestErrorReporter->error('SignSingleFileJob failed for file {fileId}', [ 'exception' => $e, + 'fileId' => $file?->getId() ?? ($argument['fileId'] ?? null), + 'signRequestId' => $signRequest?->getId() ?? ($argument['signRequestId'] ?? null), + 'signRequestUuid' => $signRequest?->getUuid() ?? ($argument['signRequestUuid'] ?? null), ]); } finally { $this->deleteCredentials($argument['userId'] ?? ($effectiveUserId ?? ''), $argument['credentialsId'] ?? null); @@ -190,4 +199,5 @@ private function deleteCredentials(string $userId, ?string $credentialsId): void } $this->credentialsManager->delete($userId, $credentialsId); } + } From 145eea911d5dcaddad88f7a2726ce6a1e09108f4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:18 -0300 Subject: [PATCH 05/34] refactor: inject StatusService dependency in signing services Update SignFileService, AsyncSigningService, and background jobs to inject StatusService explicitly instead of relying on internal creation. Improves dependency injection and testability. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/BackgroundJob/SignFileJob.php | 1 - lib/BackgroundJob/SignSingleFileJob.php | 1 - lib/Service/AsyncSigningService.php | 1 + lib/Service/SignFileService.php | 16 +++++----------- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/BackgroundJob/SignFileJob.php b/lib/BackgroundJob/SignFileJob.php index b183a0b210..39b7f6a573 100644 --- a/lib/BackgroundJob/SignFileJob.php +++ b/lib/BackgroundJob/SignFileJob.php @@ -28,7 +28,6 @@ public function __construct( */ #[\Override] public function run($argument): void { - // Handle null arguments from Nextcloud background job system $argument = is_array($argument) ? $argument : []; $this->coordinator->runSignFile($argument); } diff --git a/lib/BackgroundJob/SignSingleFileJob.php b/lib/BackgroundJob/SignSingleFileJob.php index 71b2cad3e3..f511d17c3c 100644 --- a/lib/BackgroundJob/SignSingleFileJob.php +++ b/lib/BackgroundJob/SignSingleFileJob.php @@ -29,7 +29,6 @@ public function __construct( */ #[\Override] public function run($argument): void { - // Handle null arguments from Nextcloud background job system $argument = is_array($argument) ? $argument : []; $this->coordinator->runSignSingleFile($argument); } diff --git a/lib/Service/AsyncSigningService.php b/lib/Service/AsyncSigningService.php index 57f7aa1544..5bc00303ee 100644 --- a/lib/Service/AsyncSigningService.php +++ b/lib/Service/AsyncSigningService.php @@ -49,6 +49,7 @@ public function enqueueSigningJob( $this->jobList->add(SignFileJob::class, [ 'fileId' => $libreSignFile->getId(), 'signRequestId' => $signRequest->getId(), + 'signRequestUuid' => $signRequest->getUuid(), 'userId' => $user?->getUID(), 'credentialsId' => $credentialsId, 'userUniqueIdentifier' => $userUniqueIdentifier, diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index f32e9b31bd..ec706ed5d4 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -42,6 +42,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken; +use OCA\Libresign\Service\SignRequest\StatusService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Utility\ITimeFactory; @@ -52,8 +53,6 @@ use OCP\Files\NotPermittedException; use OCP\Http\Client\IClientService; use OCP\IAppConfig; -use OCP\ICache; -use OCP\ICacheFactory; use OCP\IDateTimeZone; use OCP\IL10N; use OCP\ITempManager; @@ -81,7 +80,6 @@ class SignFileService { private string $friendlyName = ''; private ?IUser $user = null; private ?SignEngineHandler $engine = null; - private ICache $cache; private PfxProvider $pfxProvider; public function __construct( @@ -116,14 +114,13 @@ public function __construct( private PdfSignatureDetectionService $pdfSignatureDetectionService, private SequentialSigningService $sequentialSigningService, private FileStatusService $fileStatusService, + private StatusService $statusService, private IJobList $jobList, private ICredentialsManager $credentialsManager, private EnvelopeStatusDeterminer $envelopeStatusDeterminer, private TsaValidationService $tsaValidationService, - ICacheFactory $cacheFactory, PfxProvider $pfxProvider, ) { - $this->cache = $cacheFactory->createDistributed('libresign_progress'); $this->pfxProvider = $pfxProvider; } @@ -550,6 +547,7 @@ private function enqueueSigningJobForFile(SignRequestEntity $signRequest, FileEn $args = array_merge($args, [ 'fileId' => $file->getId(), 'signRequestId' => $signRequest->getId(), + 'signRequestUuid' => $signRequest->getUuid(), ]); $this->jobList->add(SignSingleFileJob::class, $args); @@ -972,15 +970,11 @@ protected function setNewStatusIfNecessary(FileEntity $libreSignFile): bool { } private function updateCacheAfterDbSave(FileEntity $libreSignFile): void { - $cacheKey = 'status_' . $libreSignFile->getUuid(); - $status = $libreSignFile->getStatus(); - $this->cache->set($cacheKey, $status, 60); // Cache for 60 seconds + $this->statusService->cacheFileStatus($libreSignFile); } private function updateEntityCacheAfterDbSave(FileEntity $file): void { - $cacheKey = 'status_' . $file->getUuid(); - $status = $file->getStatus(); - $this->cache->set($cacheKey, $status, 60); + $this->statusService->cacheFileStatus($file); } private function evaluateStatusFromSigners(): ?int { From bd44877037157599a2621d59d688e5362cecbc10 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:25 -0300 Subject: [PATCH 06/34] feat: enhance FileProgressController with detailed error info Add per-file error details to progress responses. Improve envelope progress with child file error aggregation. Change from short polling to long-polling for better efficiency. Users now see specific error messages for each file that fails during async signing, rather than generic failure messages. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileProgressController.php | 44 +++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/Controller/FileProgressController.php b/lib/Controller/FileProgressController.php index ef445223d4..55b5a2c8f3 100644 --- a/lib/Controller/FileProgressController.php +++ b/lib/Controller/FileProgressController.php @@ -53,7 +53,7 @@ public function __construct( * * @param string $uuid Sign request UUID * @param int $timeout Maximum seconds to wait (default 30) - * @return DataResponse, file?: LibresignValidateFile}, array{}>|DataResponse + * @return DataResponse, file?: LibresignValidateFile, error?: array}, array{}>|DataResponse * * 200: Status and progress returned * 404: Sign request not found @@ -64,14 +64,17 @@ public function __construct( #[PublicPage] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/progress/{uuid}', requirements: ['apiVersion' => '(v1)'])] public function checkProgressByUuid(string $uuid, int $timeout = 30): DataResponse { + $timeout = max(1, min($timeout, 30)); try { $signRequest = $this->signRequestMapper->getByUuid($uuid); $file = $this->fileMapper->getById($signRequest->getFileId()); $currentStatus = $this->progressService->getStatusCodeForSignRequest($file, $signRequest); - if ($file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value) { - $this->workerHealthService->ensureWorkerRunning(); - $currentStatus = $this->progressService->pollForStatusChange($uuid, $currentStatus, $timeout); + if ($timeout > 0) { + if ($file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value) { + $this->workerHealthService->ensureWorkerRunning(); + } + $currentStatus = $this->progressService->pollForStatusOrErrorChange($file, $signRequest, $currentStatus, $timeout); } return $this->buildStatusResponse($file, $signRequest, $currentStatus); @@ -90,12 +93,16 @@ public function checkProgressByUuid(string $uuid, int $timeout = 30): DataRespon * @param FileEntity $file The file entity * @param SignRequestEntity $signRequest The sign request entity * @param int $status Current status code - * @return DataResponse, file?: LibresignValidateFile}, array{}> - * @psalm-return DataResponse, file?: LibresignValidateFile}, array{}> + * @return DataResponse, file?: LibresignValidateFile, error?: array}, array{}> + * @psalm-return DataResponse, file?: LibresignValidateFile, error?: array}, array{}> */ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRequest, int $status): DataResponse { $statusEnum = FileStatus::tryFrom($status); $progress = $this->progressService->getSignRequestProgress($file, $signRequest); + $error = $this->progressService->getSignRequestError($signRequest->getUuid()); + + $hasFileErrors = !empty($progress['files']) && $this->hasErrorsInFiles($progress['files']); + $response = [ 'status' => $statusEnum?->name ?? 'UNKNOWN', 'statusCode' => $status, @@ -104,7 +111,16 @@ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRe 'progress' => $progress, ]; - if ($this->progressService->isProgressComplete($progress)) { + if ($error && !$hasFileErrors) { + $response['status'] = 'ERROR'; + if (!empty($error['message'])) { + $response['statusText'] = (string)$error['message']; + } + $response['error'] = $error; + } + + $hasAnyError = $error || $hasFileErrors || ($progress['errors'] ?? 0) > 0; + if (!$hasAnyError && $this->progressService->isProgressComplete($progress)) { $response['file'] = $this->fileService ->setFile($file) ->setSignRequest($signRequest) @@ -121,4 +137,18 @@ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRe return new DataResponse($response, Http::STATUS_OK); } + + /** + * Check if any file in the files list has an error + * + * @param array> $files + */ + private function hasErrorsInFiles(array $files): bool { + foreach ($files as $file) { + if (!empty($file['error'])) { + return true; + } + } + return false; + } } From a17fc610deadbbd0fc1c3c29a5998e90106c5daa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:32 -0300 Subject: [PATCH 07/34] feat: display per-file errors in SigningProgress component Show individual file error messages with error icons. Improve polling logic to handle file-level errors gracefully. Switch to long-polling (immediate retry) for better responsiveness. Add error state detection to stop polling when appropriate. Users now see which specific files failed and why during batch signing operations. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SigningProgress.vue | 164 +++++++++++++++--- 1 file changed, 138 insertions(+), 26 deletions(-) diff --git a/src/components/validation/SigningProgress.vue b/src/components/validation/SigningProgress.vue index 1c21519c3a..4296bb936d 100644 --- a/src/components/validation/SigningProgress.vue +++ b/src/components/validation/SigningProgress.vue @@ -4,11 +4,11 @@ -->