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
ba74b55
feat: add enums for SignRequestStatus and SignatureFlow
vitormattos Dec 9, 2025
b946ade
feat: add database migration for sequential signing
vitormattos Dec 9, 2025
55d0d9b
feat: update SignRequest entity for sequential signing
vitormattos Dec 9, 2025
42e3853
feat: add SequentialSigningService for signing order management
vitormattos Dec 9, 2025
51cf523
feat: integrate sequential signing in RequestSignatureService
vitormattos Dec 9, 2025
59903b8
feat: update SignFileService to support sequential signing
vitormattos Dec 9, 2025
928f578
feat: add validation for sequential signing status
vitormattos Dec 9, 2025
33d8446
feat: add admin endpoint for signature flow configuration
vitormattos Dec 9, 2025
9ee88c9
chore: remove debug logging and update dependencies
vitormattos Dec 9, 2025
8d83fa7
test: add Behat integration tests for sequential signing
vitormattos Dec 9, 2025
7adb0f2
chore: update OpenAPI specs and TypeScript types
vitormattos Dec 9, 2025
69feb74
refactor: change status from string to integer type
vitormattos Dec 9, 2025
11e8839
fix: cs
vitormattos Dec 9, 2025
b14cc50
test: fix unit tests to include SequentialSigningService mock
vitormattos Dec 9, 2025
74e2ade
chore: reorder method and change visibility
vitormattos Dec 10, 2025
3e2a04d
test: fix mock callbacks to include getFileId and getSigningOrder
vitormattos Dec 10, 2025
6502530
feat: reorder signers after deletion in sequential mode
vitormattos Dec 10, 2025
fae11f1
test: fix mock callbacks for testUpdateDatabaseWhenSign and testDispa…
vitormattos Dec 10, 2025
ee69b44
feat: add signature flow admin endpoint
vitormattos Dec 10, 2025
0367e53
feat: add signature flow configuration UI
vitormattos Dec 10, 2025
5c6fc22
chore: update OpenAPI specs for signature flow endpoint
vitormattos Dec 10, 2025
1ca80e9
feat: add vuedraggable for drag-and-drop reordering
vitormattos Dec 10, 2025
f23e9d6
feat: provide signature_flow initial state to frontend
vitormattos Dec 10, 2025
83a982f
feat: add signingOrder field to API responses and sort signers
vitormattos Dec 10, 2025
37571fa
feat: create signingOrderMixin for shared ordering logic
vitormattos Dec 10, 2025
8e3411b
feat: auto-assign signingOrder and adjust on signer deletion
vitormattos Dec 10, 2025
c9dba8d
feat: add visual indicators for signing order in Signer component
vitormattos Dec 10, 2025
b15f95c
feat: implement drag-and-drop reordering for signers
vitormattos Dec 10, 2025
9688265
feat: add manual signing order input with auto-save
vitormattos Dec 10, 2025
c86cb4a
fix: only notify signers with ABLE_TO_SIGN status in sequential flow
vitormattos Dec 10, 2025
3a44e0a
test: fix signature-flow endpoint URL in integration test
vitormattos Dec 10, 2025
70e7c35
feat: add signingOrder to OpenAPI schema definitions
vitormattos Dec 10, 2025
af5b9bf
fix: preserve insertion order when signingOrder values are equal
vitormattos Dec 10, 2025
5caf995
chore: update OpenAPI schemas with signingOrder field
vitormattos Dec 10, 2025
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
41 changes: 41 additions & 0 deletions lib/Controller/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,47 @@ public function footerTemplatePreviewPdf(string $template = '', int $width = 595
}
}

/**
* Set signature flow configuration
*
* @param string $mode Signature flow mode: 'parallel' or 'ordered_numeric'
* @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Configuration saved successfully
* 400: Invalid signature flow mode provided
* 500: Internal server error
*/
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-flow/config', requirements: ['apiVersion' => '(v1)'])]
public function setSignatureFlowConfig(string $mode): DataResponse {
try {
$signatureFlow = \OCA\Libresign\Service\SignatureFlow::from($mode);
} catch (\ValueError) {
return new DataResponse([
'error' => $this->l10n->t('Invalid signature flow mode. Use "parallel" or "ordered_numeric".'),
], Http::STATUS_BAD_REQUEST);
}

try {
if ($signatureFlow === \OCA\Libresign\Service\SignatureFlow::PARALLEL) {
$this->appConfig->deleteKey(Application::APP_ID, 'signature_flow');
} else {
$this->appConfig->setValueString(
Application::APP_ID,
'signature_flow',
$signatureFlow->value
);
}

return new DataResponse([
'message' => $this->l10n->t('Settings saved'),
]);
} catch (\Exception $e) {
return new DataResponse([
'error' => $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

/**
* Set DocMDP configuration
*
Expand Down
1 change: 1 addition & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public function index(): TemplateResponse {

$this->provideSignerSignatues();
$this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings());
$this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Service\SignatureFlow::PARALLEL->value));
$this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information'));

Util::addScript(Application::APP_ID, 'libresign-main');
Expand Down
6 changes: 4 additions & 2 deletions lib/Controller/RequestSignatureController.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ public function __construct(
/**
* Request signature
*
* Request that a file be signed by a group of people
* Request that a file be signed by a group of people.
* Each user in the users array can optionally include a 'signing_order' field
* to control the order of signatures when ordered signing flow is enabled.
*
* @param LibresignNewFile $file File object.
* @param LibresignNewSigner[] $users Collection of users who must sign the document
* @param LibresignNewSigner[] $users Collection of users who must sign the document. Each user can have: identify, displayName, description, notify, signing_order
* @param string $name The name of file to sign
* @param string|null $callback URL that will receive a POST after the document is signed
* @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
Expand Down
17 changes: 17 additions & 0 deletions lib/Db/SignRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
* @method ?array getMetadata()
* @method void setDocmdpLevel(int $docmdpLevel)
* @method int getDocmdpLevel()
* @method void setSigningOrder(int $signingOrder)
* @method int getSigningOrder()
* @method void setStatus(int $status)
* @method int getStatus()
*/
class SignRequest extends Entity {
protected ?int $fileId = null;
Expand All @@ -43,6 +47,9 @@ class SignRequest extends Entity {
protected ?string $signedHash = null;
protected ?array $metadata = null;
protected int $docmdpLevel = 0;
protected int $signingOrder = 1;
protected int $status = 0;

public function __construct() {
$this->addType('id', Types::INTEGER);
$this->addType('fileId', Types::INTEGER);
Expand All @@ -54,5 +61,15 @@ public function __construct() {
$this->addType('signedHash', Types::STRING);
$this->addType('metadata', Types::JSON);
$this->addType('docmdpLevel', Types::SMALLINT);
$this->addType('signingOrder', Types::INTEGER);
$this->addType('status', Types::SMALLINT);
}

public function getStatusEnum(): SignRequestStatus {
return SignRequestStatus::from($this->status);
}

public function setStatusEnum(SignRequestStatus $status): void {
$this->setStatus($status->value);
}
}
15 changes: 15 additions & 0 deletions lib/Db/SignRequestStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Db;

enum SignRequestStatus: int {
case DRAFT = 0;
case ABLE_TO_SIGN = 1;
case SIGNED = 2;
}
8 changes: 8 additions & 0 deletions lib/Files/TemplateLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Libresign\Files;

use OCA\Files\Event\LoadSidebar;
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
use OCA\Libresign\Helper\ValidateHelper;
Expand All @@ -18,6 +19,7 @@
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\EventDispatcher\IEventListener;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\IUserSession;

Expand All @@ -33,6 +35,7 @@ public function __construct(
private ValidateHelper $validateHelper,
private IdentifyMethodService $identifyMethodService,
private CertificateEngineFactory $certificateEngineFactory,
private IAppConfig $appConfig,
) {
}

Expand All @@ -55,6 +58,11 @@ public function handle(Event $event): void {
$this->identifyMethodService->getIdentifyMethodsSettings()
);

$this->initialState->provideInitialState(
'signature_flow',
$this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Service\SignatureFlow::PARALLEL->value)
);

try {
$this->validateHelper->canRequestSign($this->userSession->getUser());
$this->initialState->provideInitialState('can_request_sign', true);
Expand Down
23 changes: 23 additions & 0 deletions lib/Helper/ValidateHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -709,9 +709,32 @@ public function validateFileUuid(array $data): void {

public function validateSigner(string $uuid, ?IUser $user = null): void {
$this->validateSignerUuidExists($uuid);
$this->validateSignerStatus($uuid);
$this->validateIdentifyMethod($uuid, $user);
}

/**
* @throws LibresignException
*/
private function validateSignerStatus(string $uuid): void {
$signRequest = $this->signRequestMapper->getByUuid($uuid);
$status = $signRequest->getStatusEnum();

if ($status === \OCA\Libresign\Db\SignRequestStatus::DRAFT) {
throw new LibresignException(json_encode([
'action' => JSActions::ACTION_DO_NOTHING,
'errors' => [['message' => $this->l10n->t('You are not allowed to sign this document yet')]],
]));
}

if ($status === \OCA\Libresign\Db\SignRequestStatus::SIGNED) {
throw new LibresignException(json_encode([
'action' => JSActions::ACTION_DO_NOTHING,
'errors' => [['message' => $this->l10n->t('Document already signed')]],
]));
}
}

public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
$this->validateSignerUuidExists($uuid);
$signRequest = $this->signRequestMapper->getByUuid($uuid);
Expand Down
54 changes: 54 additions & 0 deletions lib/Migration/Version15000Date20251209000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Add sequential signing support
* - Adds 'signing_order', 'status', and 'released_at' columns to libresign_sign_request table
*/
class Version15000Date20251209000000 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

// Add signing order, status, and released_at to SignRequest table
if ($schema->hasTable('libresign_sign_request')) {
$tableSignRequest = $schema->getTable('libresign_sign_request');
if (!$tableSignRequest->hasColumn('signing_order')) {
$tableSignRequest->addColumn('signing_order', Types::INTEGER, [
'notnull' => true,
'default' => 1,
'comment' => 'Numeric order/stage for sequential signing (e.g., 1, 2, 3)',
]);
}
if (!$tableSignRequest->hasColumn('status')) {
$tableSignRequest->addColumn('status', Types::SMALLINT, [
'notnull' => true,
'default' => 0,
'comment' => 'Status: 0=draft, 1=able_to_sign, 2=signed',
]);
}
}

return $schema;
}
}
2 changes: 2 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* email?: string,
* account?: string,
* },
* signingOrder?: non-negative-int,
* }
* @psalm-type LibresignNewFile = array{
* base64?: string,
Expand Down Expand Up @@ -172,6 +173,7 @@
* hash_algorithm?: string,
* me: bool,
* signRequestId: non-negative-int,
* signingOrder?: non-negative-int,
* identifyMethods?: LibresignIdentifyMethod[],
* visibleElements?: LibresignVisibleElement[],
* signatureMethods?: LibresignSignatureMethods,
Expand Down
25 changes: 25 additions & 0 deletions lib/Service/FileService.php
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ private function loadLibreSignSigners(): void {
$this->fileData->signers[$index]['me'] = false;
$this->fileData->signers[$index]['signRequestId'] = $signer->getId();
$this->fileData->signers[$index]['description'] = $signer->getDescription();
$this->fileData->signers[$index]['signingOrder'] = $signer->getSigningOrder();
$this->fileData->signers[$index]['visibleElements'] = $this->getVisibleElements($signer->getId());
$this->fileData->signers[$index]['request_sign_date'] = $signer->getCreatedAt()->format(DateTimeInterface::ATOM);
if (empty($this->fileData->signers[$index]['signed'])) {
Expand Down Expand Up @@ -468,6 +469,18 @@ private function loadLibreSignSigners(): void {
}, []);
ksort($this->fileData->signers[$index]);
}

usort($this->fileData->signers, function ($a, $b) {
$orderA = $a['signingOrder'] ?? PHP_INT_MAX;
$orderB = $b['signingOrder'] ?? PHP_INT_MAX;

if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}

return ($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0);
});

$this->signersLibreSignLoaded = true;
}

Expand Down Expand Up @@ -821,6 +834,7 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers
'request_sign_date' => $signer->getCreatedAt()->format(DateTimeInterface::ATOM),
'signed' => null,
'signRequestId' => $signer->getId(),
'signingOrder' => $signer->getSigningOrder(),
'me' => array_reduce($identifyMethodsOfSigner, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool {
if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) {
if ($user->getUID() === $identifyMethod->getIdentifierValue()) {
Expand Down Expand Up @@ -888,6 +902,17 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers
$files[$key]['signers'] = [];
$files[$key]['statusText'] = $this->l10n->t('no signers');
} else {
usort($files[$key]['signers'], function ($a, $b) {
$orderA = $a['signingOrder'] ?? PHP_INT_MAX;
$orderB = $b['signingOrder'] ?? PHP_INT_MAX;

if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}

return ($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0);
});

$files[$key]['statusText'] = $this->fileMapper->getTextOfStatus((int)$files[$key]['status']);
}
unset($files[$key]['id']);
Expand Down
Loading
Loading