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/Controller/FileProgressController.php b/lib/Controller/FileProgressController.php index ef445223d4..672da46436 100644 --- a/lib/Controller/FileProgressController.php +++ b/lib/Controller/FileProgressController.php @@ -30,6 +30,10 @@ /** * @psalm-import-type LibresignValidateFile from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignProgressPayload from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignProgressError from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignProgressResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignProgressFile from \OCA\Libresign\ResponseDefinitions */ class FileProgressController extends AEnvironmentAwareController { public function __construct( @@ -53,7 +57,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|DataResponse * * 200: Status and progress returned * 404: Sign request not found @@ -64,14 +68,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 +97,19 @@ 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 + * @psalm-return DataResponse */ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRequest, int $status): DataResponse { $statusEnum = FileStatus::tryFrom($status); + /** @psalm-var LibresignProgressPayload $progress */ $progress = $this->progressService->getSignRequestProgress($file, $signRequest); + /** @psalm-var LibresignProgressError|null $error */ + $error = $this->progressService->getSignRequestError($signRequest->getUuid()); + + $hasFileErrors = !empty($progress['files']) && $this->hasErrorsInFiles($progress['files']); + + /** @psalm-var LibresignProgressResponse $response */ $response = [ 'status' => $statusEnum?->name ?? 'UNKNOWN', 'statusCode' => $status, @@ -104,7 +118,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 +144,16 @@ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRe return new DataResponse($response, Http::STATUS_OK); } + + /** + * @param list $files + */ + private function hasErrorsInFiles(array $files): bool { + foreach ($files as $file) { + if (!empty($file['error'])) { + return true; + } + } + return false; + } } 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/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 9a4ef13e82..b2efa5f24a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -242,6 +242,44 @@ * }[], * visibleElements?: LibresignVisibleElement[], * } + * @psalm-type LibresignProgressError = array{ + * message: string, + * code?: int, + * timestamp?: string, + * fileId?: int, + * signRequestId?: int, + * signRequestUuid?: string, + * } + * @psalm-type LibresignProgressFile = array{ + * id: int, + * name: string, + * status: int, + * statusText: string, + * error?: LibresignProgressError, + * } + * @psalm-type LibresignProgressPayload = array{ + * total: int, + * signed: int, + * inProgress: int, + * pending: int, + * errors?: int, + * files?: list, + * signers?: list, + * } + * @psalm-type LibresignProgressResponse = array{ + * status: string, + * statusCode: int, + * statusText: string, + * fileId: int, + * progress: LibresignProgressPayload, + * file?: LibresignValidateFile, + * error?: LibresignProgressError, + * } * @psalm-type LibresignFileListItem = array{ * fileId: int, * id: int, 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 { diff --git a/lib/Service/SignJobCoordinator.php b/lib/Service/SignJobCoordinator.php index 7f0d463204..f7e2fef4f9 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); 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..4c8d581633 --- /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'] ?? ProgressService::ERROR_CACHE_TTL); + $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/ProgressService.php b/lib/Service/SignRequest/ProgressService.php index cf71f3013b..9f9072c1a8 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,19 @@ */ class ProgressService { private ICache $cache; + public const ERROR_KEY_PREFIX = 'libresign_sign_request_error_'; + public const FILE_ERROR_KEY_PREFIX = 'libresign_file_error_'; + public const ERROR_CACHE_TTL = 300; + /** @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 +56,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 +124,212 @@ public function pollForStatusChange(string $uuid, int $initialStatus, int $timeo return $initialStatus; } + public function setSignRequestError(string $uuid, array $error, int $ttl = self::ERROR_CACHE_TTL): 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 = self::ERROR_CACHE_TTL): 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 { 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); + } +} 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, diff --git a/openapi-full.json b/openapi-full.json index 2b1f9c9d86..e94bdb2326 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1006,6 +1006,164 @@ } } }, + "ProgressError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "integer", + "format": "int64" + }, + "timestamp": { + "type": "string" + }, + "fileId": { + "type": "integer", + "format": "int64" + }, + "signRequestId": { + "type": "integer", + "format": "int64" + }, + "signRequestUuid": { + "type": "string" + } + } + }, + "ProgressFile": { + "type": "object", + "required": [ + "id", + "name", + "status", + "statusText" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/ProgressError" + } + } + }, + "ProgressPayload": { + "type": "object", + "required": [ + "total", + "signed", + "inProgress", + "pending" + ], + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "signed": { + "type": "integer", + "format": "int64" + }, + "inProgress": { + "type": "integer", + "format": "int64" + }, + "pending": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgressFile" + } + }, + "signers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "displayName", + "signed", + "status" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "signed": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "ProgressResponse": { + "type": "object", + "required": [ + "status", + "statusCode", + "statusText", + "fileId", + "progress" + ], + "properties": { + "status": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "fileId": { + "type": "integer", + "format": "int64" + }, + "progress": { + "$ref": "#/components/schemas/ProgressPayload" + }, + "file": { + "$ref": "#/components/schemas/ValidateFile" + }, + "error": { + "$ref": "#/components/schemas/ProgressError" + } + } + }, "PublicCapabilities": { "type": "object", "properties": { @@ -6221,39 +6379,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "status", - "statusCode", - "statusText", - "fileId", - "progress" - ], - "properties": { - "status": { - "type": "string" - }, - "statusCode": { - "type": "integer", - "format": "int64" - }, - "statusText": { - "type": "string" - }, - "fileId": { - "type": "integer", - "format": "int64" - }, - "progress": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "file": { - "$ref": "#/components/schemas/ValidateFile" - } - } + "$ref": "#/components/schemas/ProgressResponse" } } } diff --git a/openapi.json b/openapi.json index 823a8bb8a8..53fbc4a2fe 100644 --- a/openapi.json +++ b/openapi.json @@ -921,6 +921,164 @@ } } }, + "ProgressError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "integer", + "format": "int64" + }, + "timestamp": { + "type": "string" + }, + "fileId": { + "type": "integer", + "format": "int64" + }, + "signRequestId": { + "type": "integer", + "format": "int64" + }, + "signRequestUuid": { + "type": "string" + } + } + }, + "ProgressFile": { + "type": "object", + "required": [ + "id", + "name", + "status", + "statusText" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/ProgressError" + } + } + }, + "ProgressPayload": { + "type": "object", + "required": [ + "total", + "signed", + "inProgress", + "pending" + ], + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "signed": { + "type": "integer", + "format": "int64" + }, + "inProgress": { + "type": "integer", + "format": "int64" + }, + "pending": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgressFile" + } + }, + "signers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "displayName", + "signed", + "status" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "signed": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "ProgressResponse": { + "type": "object", + "required": [ + "status", + "statusCode", + "statusText", + "fileId", + "progress" + ], + "properties": { + "status": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "fileId": { + "type": "integer", + "format": "int64" + }, + "progress": { + "$ref": "#/components/schemas/ProgressPayload" + }, + "file": { + "$ref": "#/components/schemas/ValidateFile" + }, + "error": { + "$ref": "#/components/schemas/ProgressError" + } + } + }, "PublicCapabilities": { "type": "object", "properties": { @@ -6071,39 +6229,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "status", - "statusCode", - "statusText", - "fileId", - "progress" - ], - "properties": { - "status": { - "type": "string" - }, - "statusCode": { - "type": "integer", - "format": "int64" - }, - "statusText": { - "type": "string" - }, - "fileId": { - "type": "integer", - "format": "int64" - }, - "progress": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "file": { - "$ref": "#/components/schemas/ValidateFile" - } - } + "$ref": "#/components/schemas/ProgressResponse" } } } diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 1fa8419ad0..7400dea9b6 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -962,7 +962,13 @@ export default { this.hasLoading = false }, async sign() { - const uuid = this.filesStore.getFile().signUuid + const file = this.filesStore.getFile() + if (file?.status === FILE_STATUS.SIGNING_IN_PROGRESS) { + this.validationFile() + return + } + + const uuid = file.signUuid if (this.useModal) { const route = router.resolve({ name: 'SignPDFExternal', params: { uuid } }) this.modalSrc = route.href diff --git a/src/components/validation/SigningProgress.vue b/src/components/validation/SigningProgress.vue index 1c21519c3a..51cc05142c 100644 --- a/src/components/validation/SigningProgress.vue +++ b/src/components/validation/SigningProgress.vue @@ -4,11 +4,14 @@ -->