Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
20b84e0
feat: add error reporting infrastructure for async signing
vitormattos Jan 26, 2026
598f90a
feat: extend ProgressService with per-file error tracking
vitormattos Jan 26, 2026
66a50e4
refactor: inject StatusCacheService into StatusService
vitormattos Jan 26, 2026
ba96277
feat: integrate error reporting in SignJobCoordinator
vitormattos Jan 26, 2026
145eea9
refactor: inject StatusService dependency in signing services
vitormattos Jan 26, 2026
bd44877
feat: enhance FileProgressController with detailed error info
vitormattos Jan 26, 2026
a17fc61
feat: display per-file errors in SigningProgress component
vitormattos Jan 26, 2026
b8a6a4a
feat: improve async signing UX in Validation view
vitormattos Jan 26, 2026
99b1359
test: add unit tests for error reporting infrastructure
vitormattos Jan 26, 2026
63d715f
test: extend ProgressServiceTest with error tracking tests
vitormattos Jan 26, 2026
5e387e0
test: update existing tests for new dependencies
vitormattos Jan 26, 2026
b13b215
test: add comprehensive tests for FileProgressController
vitormattos Jan 26, 2026
8c8ae56
chore: regenerate OpenAPI spec and TypeScript types
vitormattos Jan 26, 2026
28e9457
fix: reduce mixed usage and use specific types
vitormattos Jan 26, 2026
0ae800b
fix: update documentation
vitormattos Jan 26, 2026
a9d499c
chore: move cache ttl to a constant
vitormattos Jan 26, 2026
890210a
fix: cs
vitormattos Jan 26, 2026
a3418e7
feat: add new response type to clean code
vitormattos Jan 26, 2026
38dccd5
chore: update documentation
vitormattos Jan 26, 2026
3ece2e1
fix: update docblock
vitormattos Jan 26, 2026
3946029
fix: typing
vitormattos Jan 26, 2026
4e1a4c3
fix: psalm
vitormattos Jan 26, 2026
c9b6940
refactor: remove empty row
vitormattos Jan 26, 2026
4753c53
fix: indent
vitormattos Jan 26, 2026
10d78e7
chore: clean code
vitormattos Jan 26, 2026
180e765
fix: improve unit test
vitormattos Jan 26, 2026
dd664f8
chore: tests improvements
vitormattos Jan 26, 2026
677a82f
refactor: use ncnotecard
vitormattos Jan 26, 2026
3c9e3b2
chore: remove unecessary comment
vitormattos Jan 26, 2026
81153a2
refactor: cleanup tests
vitormattos Jan 26, 2026
68f087a
feat: cover with more dataproviders
vitormattos Jan 26, 2026
09bb985
feat: cover with more scenarios
vitormattos Jan 26, 2026
22f907c
feat: cover with more tests
vitormattos Jan 26, 2026
6e91822
fix: cs
vitormattos Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion lib/BackgroundJob/SignFileJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 0 additions & 1 deletion lib/BackgroundJob/SignSingleFileJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
49 changes: 42 additions & 7 deletions lib/Controller/FileProgressController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -53,7 +57,7 @@ public function __construct(
*
* @param string $uuid Sign request UUID
* @param int $timeout Maximum seconds to wait (default 30)
* @return DataResponse<Http::STATUS_OK, array{status: string, statusCode: int, statusText: string, fileId: int, progress: array<string, mixed>, file?: LibresignValidateFile}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string, status: string}, array{}>
* @return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string, status: string}, array{}>
*
* 200: Status and progress returned
* 404: Sign request not found
Expand All @@ -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);
Expand All @@ -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<Http::STATUS_OK, array{status: string, statusCode: int, statusText: string, fileId: int, progress: array<string, mixed>, file?: LibresignValidateFile}, array{}>
* @psalm-return DataResponse<Http::STATUS_OK, array{status: string, statusCode: int, statusText: string, fileId: int, progress: array<string, mixed>, file?: LibresignValidateFile}, array{}>
* @return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>
* @psalm-return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>
*/
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,
Expand All @@ -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)
Expand All @@ -121,4 +144,16 @@ private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRe

return new DataResponse($response, Http::STATUS_OK);
}

/**
* @param list<LibresignProgressFile> $files
*/
private function hasErrorsInFiles(array $files): bool {
foreach ($files as $file) {
if (!empty($file['error'])) {
return true;
}
}
return false;
}
}
16 changes: 16 additions & 0 deletions lib/Db/SignRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
38 changes: 38 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<LibresignProgressFile>,
* signers?: list<array{
* id: int,
* displayName: string,
* signed: ?string,
* status: int,
* }>,
* }
* @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,
Expand Down
1 change: 1 addition & 0 deletions lib/Service/AsyncSigningService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 5 additions & 11 deletions lib/Service/SignFileService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 16 additions & 7 deletions lib/Service/SignJobCoordinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,18 +30,23 @@ 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');
}

[$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());
Expand All @@ -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);
Expand All @@ -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()
Expand All @@ -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);
Expand Down
Loading
Loading