From dfa6821fc3ea238286b2375d5996b355a1d32908 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 6f35dbb6f41de13470083b8b562b57439f93b375 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 7d8bf0279e9375da6b0174631b9e86c0c0d2b401 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 dd037b6005592fc070bfc7dd875b16ddffc18a88 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 e00e899f83ff7bf7cbadad75b13416991330f935 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 d8e423c764e4f002dedb061c6854de4fa9e4bf2a 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 18bced3ef6398e72d857812760aec4cf465c1802 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 @@ -->