Skip to content
65 changes: 65 additions & 0 deletions lib/Events/NotificationDeliveryStatusChangedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

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

namespace OCA\Libresign\Events;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\NotificationDelivery;
use OCA\Libresign\Enum\NotificationDeliveryState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IWebhookCompatibleEvent;

/** Result of one persisted channel-submission attempt, NOT recipient delivery. */
final class NotificationDeliveryStatusChangedEvent extends Event implements IWebhookCompatibleEvent {
public const CONTRACT_VERSION = 1;

private function __construct(
private readonly string $requestUuid,
private readonly string $requestType,
private readonly int $deliveryId,
private readonly string $channel,
private readonly string $state,
private readonly int $attempt,
private readonly string $eventId,
) {
parent::__construct();
}

public static function fromPersistedDelivery(FileEntity $root, NotificationDelivery $delivery): ?self {
$state = NotificationDeliveryState::tryFrom($delivery->getState());
if ($root->getUuid() === '' || $delivery->getId() === null || $delivery->getAttemptCount() < 1
|| $state === null
|| in_array($state, [NotificationDeliveryState::PENDING, NotificationDeliveryState::SENDING], true)) {
return null;
}

return new self(
$root->getUuid(),
$root->isEnvelope() ? 'envelope' : 'document',
$delivery->getId(),
$delivery->getChannel(),
$state->value,
$delivery->getAttemptCount(),
'libresign.notification.delivery.v1:' . $delivery->getId() . ':' . $delivery->getAttemptCount() . ':' . $state->value,
);
}

#[\Override]
public function getWebhookSerializable(): array {
return [
'contractVersion' => self::CONTRACT_VERSION,
'eventId' => $this->eventId,
'requestUuid' => $this->requestUuid,
'requestType' => $this->requestType,
'deliveryId' => $this->deliveryId,
'channel' => $this->channel,
'state' => $this->state,
'attempt' => $this->attempt,
];
}
}
64 changes: 64 additions & 0 deletions lib/Events/SignerRejectedWebhookEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

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

namespace OCA\Libresign\Events;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\SignRequest;
use OCA\Libresign\Enum\SignRequestStatus;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IWebhookCompatibleEvent;

/** One committed business rejection, independent of identification-method count. */
final class SignerRejectedWebhookEvent extends Event implements IWebhookCompatibleEvent {
public const CONTRACT_VERSION = 1;

private function __construct(
private readonly string $requestUuid,
private readonly string $requestType,
private readonly string $documentUuid,
private readonly int $signerId,
private readonly bool $workflowCanceled,
private readonly string $eventId,
) {
parent::__construct();
}

public static function afterRejection(FileEntity $root, FileEntity $document, SignRequest $signer, bool $workflowCanceled): ?self {
if ($root->getUuid() === '' || $document->getUuid() === ''
|| $signer->getId() === null
|| $signer->getStatusEnum() !== SignRequestStatus::REJECTED
|| $signer->getRejectedAt() === null) {
return null;
}

$identity = implode(':', [$root->getUuid(), $document->getUuid(), $signer->getId(), $signer->getRejectedAt()->format('U.u')]);
return new self(
$root->getUuid(),
$root->isEnvelope() ? 'envelope' : 'document',
$document->getUuid(),
$signer->getId(),
$workflowCanceled,
'libresign.signer.rejected.v1:' . hash('sha256', $identity),
);
}

#[\Override]
public function getWebhookSerializable(): array {
return [
'contractVersion' => self::CONTRACT_VERSION,
'eventId' => $this->eventId,
'requestUuid' => $this->requestUuid,
'requestType' => $this->requestType,
'documentUuid' => $this->documentUuid,
'signerId' => $this->signerId,
'status' => 'rejected',
'workflowCanceled' => $this->workflowCanceled,
];
}
}
62 changes: 62 additions & 0 deletions lib/Events/SignerSignedWebhookEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

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

namespace OCA\Libresign\Events;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\SignRequest;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IWebhookCompatibleEvent;

/**
* An individual document-level signer action, including envelope children.
* The secret signing invitation UUID is never exposed.
*/
final class SignerSignedWebhookEvent extends Event implements IWebhookCompatibleEvent {
public const CONTRACT_VERSION = 1;

private function __construct(
private readonly string $requestUuid,
private readonly string $requestType,
private readonly string $documentUuid,
private readonly int $signerId,
private readonly string $eventId,
) {
parent::__construct();
}

public static function afterSigning(FileEntity $root, FileEntity $document, SignRequest $signer): ?self {
if ($root->getUuid() === '' || $document->getUuid() === ''
|| $signer->getId() === null || $signer->getSigned() === null
|| !is_string($signer->getSignedHash()) || $signer->getSignedHash() === '') {
return null;
}

$identity = implode(':', [$root->getUuid(), $document->getUuid(), $signer->getId(), $signer->getSignedHash()]);
return new self(
$root->getUuid(),
$root->isEnvelope() ? 'envelope' : 'document',
$document->getUuid(),
$signer->getId(),
'libresign.signer.signed.v1:' . hash('sha256', $identity),
);
}

#[\Override]
public function getWebhookSerializable(): array {
return [
'contractVersion' => self::CONTRACT_VERSION,
'eventId' => $this->eventId,
'requestUuid' => $this->requestUuid,
'requestType' => $this->requestType,
'documentUuid' => $this->documentUuid,
'signerId' => $this->signerId,
'status' => 'signed',
];
}
}
48 changes: 48 additions & 0 deletions lib/Events/SigningRequestCanceledEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

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

namespace OCA\Libresign\Events;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Enum\FileStatus;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IWebhookCompatibleEvent;

/** Aggregate workflow cancellation; unrelated to signer-removal notices. */
final class SigningRequestCanceledEvent extends Event implements IWebhookCompatibleEvent {
public const CONTRACT_VERSION = 1;

private function __construct(
private readonly string $requestUuid,
private readonly string $requestType,
) {
parent::__construct();
}

public static function fromTransition(FileEntity $file, int $previousStatus): ?self {
if ($file->hasParent()
|| $previousStatus === FileStatus::CANCELED->value
|| $file->getStatus() !== FileStatus::CANCELED->value
|| $file->getUuid() === '') {
return null;
}

return new self($file->getUuid(), $file->isEnvelope() ? 'envelope' : 'document');
}

#[\Override]
public function getWebhookSerializable(): array {
return [
'contractVersion' => self::CONTRACT_VERSION,
'eventId' => 'libresign.workflow.canceled.v1:' . $this->requestUuid,
'requestUuid' => $this->requestUuid,
'requestType' => $this->requestType,
'status' => 'canceled',
];
}
}
48 changes: 48 additions & 0 deletions lib/Events/SigningRequestReadyEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

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

namespace OCA\Libresign\Events;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Enum\FileStatus;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IWebhookCompatibleEvent;

/** Public event for a workflow transitioning from draft to ready to sign. */
final class SigningRequestReadyEvent extends Event implements IWebhookCompatibleEvent {
public const CONTRACT_VERSION = 1;

private function __construct(
private readonly string $requestUuid,
private readonly string $requestType,
) {
parent::__construct();
}

public static function fromTransition(FileEntity $file, int $previousStatus): ?self {
if ($file->hasParent()
|| $previousStatus !== FileStatus::DRAFT->value
|| $file->getStatus() !== FileStatus::ABLE_TO_SIGN->value
|| $file->getUuid() === '') {
return null;
}

return new self($file->getUuid(), $file->isEnvelope() ? 'envelope' : 'document');
}

#[\Override]
public function getWebhookSerializable(): array {
return [
'contractVersion' => self::CONTRACT_VERSION,
'eventId' => 'libresign.workflow.ready.v1:' . $this->requestUuid,
'requestUuid' => $this->requestUuid,
'requestType' => $this->requestType,
'status' => 'ready_to_sign',
];
}
}
15 changes: 14 additions & 1 deletion lib/Service/FileStatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,32 @@
use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Enum\FileStatus;
use OCA\Libresign\Events\SigningRequestReadyEvent;
use OCA\Libresign\Service\SignRequest\StatusCacheService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\EventDispatcher\IEventDispatcher;

class FileStatusService {
public const POLICY_SNAPSHOT_FROZEN_AT = 'policy_snapshot_frozen_at';

public function __construct(
private FileMapper $fileMapper,
private StatusCacheService $statusCacheService,
private IEventDispatcher $eventDispatcher,
) {
}

public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus): FileEntity {
public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus, bool $publishReady = true): FileEntity {
$currentStatus = $file->getStatus();
if ($newStatus > $currentStatus) {
$this->markPolicySnapshotFrozen($file, $newStatus);
$file->setStatus($newStatus);
$this->touchStatusChangedAt($file);
$this->fileMapper->update($file);
$this->statusCacheService->setStatus($file->getUuid(), $newStatus);
if ($publishReady && $currentStatus === FileStatus::DRAFT->value) {
$this->dispatchReadyRequest($file);
}

if ($file->hasParent()) {
$this->propagateStatusToParent($file->getParentFileId());
Expand All @@ -42,6 +48,13 @@ public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus): Fil
return $file;
}

public function dispatchReadyRequest(FileEntity $file): void {
$event = SigningRequestReadyEvent::fromTransition($file, FileStatus::DRAFT->value);
if ($event !== null) {
$this->eventDispatcher->dispatchTyped($event);
}
}

public function invalidateCachedStatus(FileEntity $file): void {
$this->statusCacheService->invalidateStatus($file->getUuid());
}
Expand Down
44 changes: 44 additions & 0 deletions lib/Service/Notification/NotificationDeliveryEventPublisher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

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

namespace OCA\Libresign\Service\Notification;

use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\NotificationDeliveryMapper;
use OCA\Libresign\Events\NotificationDeliveryStatusChangedEvent;
use OCP\EventDispatcher\IEventDispatcher;
use Psr\Log\LoggerInterface;

class NotificationDeliveryEventPublisher {
public function __construct(
private NotificationDeliveryMapper $deliveries,
private FileMapper $files,
private IEventDispatcher $events,
private LoggerInterface $logger,
) {
}

public function publish(int $deliveryId): void {
try {
$delivery = $this->deliveries->findById($deliveryId);
$file = $this->files->getById($delivery->getFileId());
$root = $file->hasParent()
? $this->files->getById($file->getParentFileId())
: $file;
$event = NotificationDeliveryStatusChangedEvent::fromPersistedDelivery($root, $delivery);
if ($event !== null) {
$this->events->dispatchTyped($event);
}
} catch (\Exception $e) {
$this->logger->warning('Unable to publish persisted notification outcome', [
'deliveryId' => $deliveryId,
'errorType' => get_debug_type($e),
]);
}
}
}
Loading
Loading