diff --git a/.env b/.env index 1714966d4..b2eecb78f 100644 --- a/.env +++ b/.env @@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$' ###< symfony/framework-bundle ### ## Wopi server for editing documents online -WOPI_SERVER=http://collabora:9980 +EDITOR_SERVER=http://collabora:9980 # must be manually set in .env.local # ADMIN_PASSWORD= diff --git a/.env.test b/.env.test index f84920e54..c78a1bc63 100644 --- a/.env.test +++ b/.env.test @@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars ASYNC_UPLOAD_TEMP_URL_KEY= ASYNC_UPLOAD_TEMP_URL_BASE_PATH= ASYNC_UPLOAD_TEMP_URL_CONTAINER= + +EDITOR_SERVER=https://localhost:9980 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acd66a42e..c1fdebf43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ unit_tests: - php tests/console chill:db:sync-views --env=test - php -d memory_limit=2G tests/console cache:clear --env=test - php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test - - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration + - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration artifacts: expire_in: 1 day paths: diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php new file mode 100644 index 000000000..bff5c60c1 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php @@ -0,0 +1,67 @@ +objectVersion->addPointInTime($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getByUser(): ?User + { + return $this->byUser; + } + + public function getObjectVersion(): StoredObjectVersion + { + return $this->objectVersion; + } + + public function getReason(): StoredObjectPointInTimeReasonEnum + { + return $this->reason; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php new file mode 100644 index 000000000..9f03c7279 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php @@ -0,0 +1,18 @@ + ''])] private string $filename = ''; + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection&Selectable $pointInTimes; + public function __construct( /** * The stored object associated with this version. @@ -77,6 +86,7 @@ class StoredObjectVersion implements TrackCreationInterface ?string $filename = null, ) { $this->filename = $filename ?? self::generateFilename($this); + $this->pointInTimes = new ArrayCollection(); } public static function generateFilename(StoredObjectVersion $storedObjectVersion): string @@ -124,4 +134,40 @@ class StoredObjectVersion implements TrackCreationInterface { return $this->version; } + + /** + * @return Collection&Selectable + */ + public function getPointInTimes(): Selectable&Collection + { + return $this->pointInTimes; + } + + public function hasPointInTimes(): bool + { + return $this->pointInTimes->count() > 0; + } + + /** + * @return $this + * + * @internal use @see{StoredObjectPointInTime} constructor instead + */ + public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if (!$this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->add($storedObjectPointInTime); + } + + return $this; + } + + public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if ($this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->removeElement($storedObjectPointInTime); + } + + return $this; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php new file mode 100644 index 000000000..c5c923ac9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php @@ -0,0 +1,27 @@ + + */ +class StoredObjectPointInTimeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StoredObjectPointInTime::class); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php index 1ab9b9edd..60ea07420 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php @@ -62,7 +62,7 @@ class StoredObjectVersionRepository implements ObjectRepository * * @return iterable returns an iterable with the IDs of the versions */ - public function findIdsByVersionsOlderThanDateAndNotLastVersion(\DateTimeImmutable $beforeDate): iterable + public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable { $results = $this->connection->executeQuery( self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION, @@ -83,6 +83,8 @@ class StoredObjectVersionRepository implements ObjectRepository sov.createdat < ?::timestamp AND sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id) + AND + NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id) SQL; public function getClassName(): string diff --git a/src/Bundle/ChillDocStoreBundle/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandler.php b/src/Bundle/ChillDocStoreBundle/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandler.php index 720bc000f..e97781a15 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandler.php @@ -12,12 +12,11 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Chill\MainBundle\Workflow\SignatureStepStateChanger; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; final readonly class PdfSignedMessageHandler implements MessageHandlerInterface @@ -33,7 +32,7 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface private StoredObjectManagerInterface $storedObjectManager, private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository, private EntityManagerInterface $entityManager, - private ClockInterface $clock, + private SignatureStepStateChanger $signatureStepStateChanger, ) {} public function __invoke(PdfSignedMessage $message): void @@ -54,8 +53,8 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface $this->storedObjectManager->write($storedObject, $message->content); - $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now()); - $signature->setZoneSignatureIndex($message->signatureZoneIndex); + $this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex); + $this->entityManager->flush(); $this->entityManager->clear(); } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php index d190a4e45..d2494266b 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php @@ -50,7 +50,7 @@ final readonly class RemoveOldVersionCronJob implements CronJobInterface $deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL)); $maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0; - foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersion($deleteBeforeDate) as $id) { + foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) { $this->messageBus->dispatch(new RemoveOldVersionMessage($id)); $maxDeleted = max($maxDeleted, $id); } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php index 69c9f283d..ea3e37be7 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php @@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; /** @@ -49,13 +50,18 @@ final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInt $this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]); $storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId); - $storedObject = $storedObjectVersion->getStoredObject(); if (null === $storedObjectVersion) { $this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]); throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId); } + if ($storedObjectVersion->hasPointInTimes()) { + throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time'); + } + + $storedObject = $storedObjectVersion->getStoredObject(); + $this->storedObjectManager->delete($storedObjectVersion); // to ensure an immediate deletion $this->entityManager->remove($storedObjectVersion); diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php new file mode 100644 index 000000000..abfcca4cc --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php @@ -0,0 +1,75 @@ +mimeTypes->getMimeTypes($convertTo)[0] ?? null; + + if (null === $newMimeType) { + throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo)); + } + + $currentVersion = $storedObject->getCurrentVersion(); + + if ($currentVersion->getType() === $newMimeType) { + throw new \UnexpectedValueException('Already at the same mime type'); + } + + $content = $this->storedObjectManager->read($currentVersion); + + try { + $converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo); + } catch (\RuntimeException $e) { + throw new \RuntimeException('could not store a new version for document', previous: $e); + } + + $pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType); + + return [$pointInTime, $version]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php index ace122bea..4b134a1fb 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php @@ -35,7 +35,7 @@ class StoredObjectVersionRepositoryTest extends KernelTestCase $repository = new StoredObjectVersionRepository($this->entityManager); // get old version, to get a chance to get one - $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersion(new \DateTimeImmutable('1970-01-01')); + $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(new \DateTimeImmutable('1970-01-01')); self::assertIsIterable($actual); self::assertContainsOnly('int', $actual); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandlerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandlerTest.php index 471dc8f9a..fed1b1274 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandlerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Signature/Driver/BaseSigner/PdfSignedMessageHandlerTest.php @@ -20,12 +20,12 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Chill\MainBundle\Workflow\SignatureStepStateChanger; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\PersonBundle\Entity\Person; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; -use Symfony\Component\Clock\MockClock; /** * @internal @@ -45,6 +45,9 @@ class PdfSignedMessageHandlerTest extends TestCase $entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User()); $step = $entityWorkflow->getCurrentStep(); $signature = $step->getSignatures()->first(); + $stateChanger = $this->createMock(SignatureStepStateChanger::class); + $stateChanger->expects(self::once())->method('markSignatureAsSigned') + ->with($signature, 99); $handler = new PdfSignedMessageHandler( new NullLogger(), @@ -52,15 +55,12 @@ class PdfSignedMessageHandlerTest extends TestCase $this->buildStoredObjectManager($storedObject, $expectedContent = '1234'), $this->buildSignatureRepository($signature), $this->buildEntityManager(true), - new MockClock('now'), + $stateChanger, ); // we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once // with the content "1234" $handler(new PdfSignedMessage(10, 99, $expectedContent)); - - self::assertEquals('signed', $signature->getState()->value); - self::assertEquals(99, $signature->getZoneSignatureIndex()); } private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php index 937253c81..df75eea93 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php @@ -46,7 +46,7 @@ class RemoveOldVersionCronJobTest extends KernelTestCase $clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00'))); $repository = $this->createMock(StoredObjectVersionRepository::class); $repository->expects($this->once()) - ->method('findIdsByVersionsOlderThanDateAndNotLastVersion') + ->method('findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime') ->with(new \DateTime('2023-10-03 00:00:00', new \DateTimeZone('+00:00'))) ->willReturnCallback(function ($arg) { yield 1; diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php index e09a2dc1e..39412b9f7 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php @@ -220,7 +220,8 @@ final class StoredObjectManagerTest extends TestCase ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl( $method, 'https://example.com/'.$objectName, - new \DateTimeImmutable('1 hours') + new \DateTimeImmutable('1 hours'), + $objectName )); $storedObjectManager = new StoredObjectManager($httpClient, $tempUrlGenerator); @@ -306,7 +307,8 @@ final class StoredObjectManagerTest extends TestCase ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl( $method, 'https://example.com/'.$objectName, - new \DateTimeImmutable('1 hours') + new \DateTimeImmutable('1 hours'), + $objectName )); $manager = new StoredObjectManager($client, $tempUrlGenerator); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php new file mode 100644 index 000000000..da3ed9210 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php @@ -0,0 +1,61 @@ +registerVersion(type: 'text/html'); + + $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->read($currentVersion)->willReturn('1234'); + $storedObjectManager->write($storedObject, '5678', 'application/pdf')->shouldBeCalled() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + return $storedObject->registerVersion(type: $args[2]); + }); + + $converter = $this->prophesize(WopiConverter::class); + $converter->convert('fr', '1234', 'application/pdf', 'pdf')->shouldBeCalled() + ->willReturn('5678'); + + $converter = new StoredObjectToPdfConverter($storedObjectManager->reveal(), $converter->reveal(), MimeTypes::getDefault()); + + $actual = $converter->addConvertedVersion($storedObject, 'fr'); + + self::assertIsArray($actual); + self::assertInstanceOf(StoredObjectPointInTime::class, $actual[0]); + self::assertSame($currentVersion, $actual[0]->getObjectVersion()); + self::assertInstanceOf(StoredObjectVersion::class, $actual[1]); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php new file mode 100644 index 000000000..572cb192c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php @@ -0,0 +1,208 @@ +registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldBeCalledOnce() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + $pointInTime = new StoredObjectPointInTime($storedObject->getCurrentVersion(), StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $newVersion = $storedObject->registerVersion(filename: 'next'); + + return [$pointInTime, $newVersion]; + }); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertNotSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertTrue($previousVersion->hasPointInTimes()); + self::assertCount(2, $storedObject->getVersions()); + self::assertEquals('next', $storedObject->getCurrentVersion()->getFilename()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToNotASignatureStep(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_something', ['context' => $dto, 'transition' => 'to_something', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('something', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureAlreadyAPdf(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(type: 'application/pdf'); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureWithNoStoredObject(): void + { + $entityWorkflow = new EntityWorkflow(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion(Argument::type(StoredObject::class), 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn(null); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + } + + private function buildRegistry(EventSubscriberInterface $eventSubscriber): Registry + { + $builder = new DefinitionBuilder(); + $builder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'signature', 'something']) + ->addTransition(new Transition('to_something', 'initial', 'something')) + ->addTransition(new Transition('to_signature', 'initial', 'signature')); + + $metadataStore = new InMemoryMetadataStore([], ['signature' => ['isSignature' => ['user']]]); + $builder->setMetadataStore($metadataStore); + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($eventSubscriber); + + $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher, 'dummy'); + + $supports = new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }; + + $registry = new Registry(); + $registry->addWorkflow($workflow, $supports); + + return $registry; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php new file mode 100644 index 000000000..0d5fe9323 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php @@ -0,0 +1,75 @@ + 'convertToPdfBeforeSignatureStepEvent', + ]; + } + + public function convertToPdfBeforeSignatureStepEvent(CompletedEvent $event): void + { + $entityWorkflow = $event->getSubject(); + if (!$entityWorkflow instanceof EntityWorkflow) { + return; + } + + $tos = $event->getTransition()->getTos(); + $workflow = $event->getWorkflow(); + $metadataStore = $workflow->getMetadataStore(); + + foreach ($tos as $to) { + $metadata = $metadataStore->getPlaceMetadata($to); + if (array_key_exists('isSignature', $metadata) && 0 < count($metadata['isSignature'])) { + $this->convertToPdf($entityWorkflow); + + return; + } + } + } + + private function convertToPdf(EntityWorkflow $entityWorkflow): void + { + $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); + + if (null === $storedObject) { + return; + } + + if ('application/pdf' === $storedObject->getCurrentVersion()->getType()) { + return; + } + + $this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $this->requestStack->getCurrentRequest()->getLocale(), 'pdf'); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php new file mode 100644 index 000000000..21906b168 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php @@ -0,0 +1,49 @@ +addSql('CREATE SEQUENCE chill_doc.stored_object_point_in_time_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_doc.stored_object_point_in_time (id INT NOT NULL, stored_object_version_id INT NOT NULL, reason TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_CC83C7B81D0AB8B9 ON chill_doc.stored_object_point_in_time (stored_object_version_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B8D23C0240 ON chill_doc.stored_object_point_in_time (byUser_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B83174800F ON chill_doc.stored_object_point_in_time (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object_point_in_time.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B81D0AB8B9 FOREIGN KEY (stored_object_version_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B8D23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix SET DEFAULT \'\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename SET DEFAULT \'\''); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_doc.stored_object_point_in_time_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B81D0AB8B9'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B8D23C0240'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B83174800F'); + $this->addSql('DROP TABLE chill_doc.stored_object_point_in_time'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename DROP DEFAULT'); + } +} diff --git a/src/Bundle/ChillMainBundle/Controller/WorkflowController.php b/src/Bundle/ChillMainBundle/Controller/WorkflowController.php index 4e4b4928a..2f8a0fd55 100644 --- a/src/Bundle/ChillMainBundle/Controller/WorkflowController.php +++ b/src/Bundle/ChillMainBundle/Controller/WorkflowController.php @@ -14,16 +14,17 @@ namespace Chill\MainBundle\Controller; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; -use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Form\WorkflowSignatureMetadataType; use Chill\MainBundle\Form\WorkflowStepType; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; +use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter; use Chill\MainBundle\Security\ChillSecurity; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NonUniqueResultException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; @@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Workflow\Registry; @@ -51,6 +53,7 @@ class WorkflowController extends AbstractController private readonly ChillSecurity $security, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, private readonly ClockInterface $clock, + private readonly EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository, ) {} #[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')] @@ -281,6 +284,9 @@ class WorkflowController extends AbstractController ); } + /** + * @throws NonUniqueResultException + */ #[Route(path: '/{_locale}/main/workflow/{id}/show', name: 'chill_main_workflow_show')] public function show(EntityWorkflow $entityWorkflow, Request $request): Response { @@ -374,7 +380,20 @@ class WorkflowController extends AbstractController #[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/metadata', name: 'chill_main_workflow_signature_metadata')] public function addSignatureMetadata(int $signature_id, Request $request): Response { - $signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id); + $signature = $this->entityWorkflowStepSignatureRepository->find($signature_id); + + if (null === $signature) { + throw new NotFoundHttpException('signature not found'); + } + + if ($signature->isSigned()) { + $this->addFlash( + 'notice', + $this->translator->trans('workflow.signature_zone.already_signed_alert') + ); + + return $this->redirectToRoute('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]); + } if ($signature->getSigner() instanceof User) { return $this->redirectToRoute('chill_main_workflow_signature_add', [ diff --git a/src/Bundle/ChillMainBundle/Controller/WorkflowOnHoldController.php b/src/Bundle/ChillMainBundle/Controller/WorkflowOnHoldController.php new file mode 100644 index 000000000..077b8effa --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/WorkflowOnHoldController.php @@ -0,0 +1,98 @@ +getCurrentStep(); + $currentUser = $this->security->getUser(); + + if (!$currentUser instanceof User) { + throw new AccessDeniedHttpException('only user can put a workflow on hold'); + } + + $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $enabledTransitions = $workflow->getEnabledTransitions($entityWorkflow); + + if (0 === count($enabledTransitions)) { + throw new AccessDeniedHttpException('You are not allowed to apply any transitions to this workflow, therefore you cannot toggle the hold status.'); + } + + $stepHold = new EntityWorkflowStepHold($currentStep, $currentUser); + + $this->entityManager->persist($stepHold); + $this->entityManager->flush(); + + return new RedirectResponse( + $this->urlGenerator->generate( + 'chill_main_workflow_show', + ['id' => $entityWorkflow->getId()] + ) + ); + } + + #[Route(path: '/{_locale}/main/workflow/{id}/remove_hold', name: 'chill_main_workflow_remove_hold')] + public function removeOnHold(EntityWorkflowStep $entityWorkflowStep): Response + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException('only user can remove workflow on hold'); + } + + if (!$entityWorkflowStep->isOnHoldByUser($user)) { + throw new AccessDeniedHttpException('You are not allowed to remove workflow on hold'); + } + + $hold = $entityWorkflowStep->getHoldsOnStep()->findFirst(fn (int $index, EntityWorkflowStepHold $entityWorkflowStepHold) => $user === $entityWorkflowStepHold->getByUser()); + + if (null === $hold) { + // this should not happens... + throw new NotFoundHttpException(); + } + + $this->entityManager->remove($hold); + $this->entityManager->flush(); + + return new RedirectResponse( + $this->urlGenerator->generate( + 'chill_main_workflow_show', + ['id' => $entityWorkflowStep->getEntityWorkflow()->getId()] + ) + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php index 5a3359c46..08b5ee41f 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php @@ -38,7 +38,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface /** * @var Collection */ - #[ORM\OneToMany(targetEntity: EntityWorkflowComment::class, mappedBy: 'entityWorkflow', orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowComment::class, orphanRemoval: true)] private Collection $comments; #[ORM\Id] @@ -56,7 +56,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface * @var Collection&Selectable */ #[Assert\Valid(traverse: true)] - #[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])] private Collection&Selectable $steps; @@ -339,8 +339,6 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface public function isFreeze(): bool { - $steps = $this->getStepsChained(); - foreach ($this->getStepsChained() as $step) { if ($step->isFreezeAfter()) { return true; @@ -350,6 +348,11 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface return false; } + public function isOnHoldByUser(User $user): bool + { + return $this->getCurrentStep()->isOnHoldByUser($user); + } + public function isUserSubscribedToFinal(User $user): bool { return $this->subscriberToFinal->contains($user); @@ -480,4 +483,41 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface return $this->steps->get($this->steps->count() - 2); } + + public function isOnHoldAtCurrentStep(): bool + { + return $this->getCurrentStep()->getHoldsOnStep()->count() > 0; + } + + /** + * Determines if the workflow has become stale after a given date. + * + * This function checks the creation date and the transition states of the workflow steps. + * A workflow is considered stale if: + * - The creation date is before the given date and no transitions have occurred since the creation. + * - Or if there are no transitions after the given date. + * + * @param \DateTimeImmutable $at the date to compare against the workflow's status + * + * @return bool true if the workflow is stale after the given date, false otherwise + */ + public function isStaledAt(\DateTimeImmutable $at): bool + { + // if there is no transition since the creation, then the workflow is staled + if ('initial' === $this->getCurrentStep()->getCurrentStep() + && null === $this->getCurrentStep()->getTransitionAt() + ) { + if (null === $this->getCreatedAt()) { + return false; + } + + if ($this->getCreatedAt() < $at) { + return true; + } + + return false; + } + + return $this->getCurrentStepChained()->getPrevious()->getTransitionAt() < $at; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 953fb31b1..c6a849da5 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -98,12 +98,19 @@ class EntityWorkflowStep #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] private ?string $transitionByEmail = null; + /** + * @var \Doctrine\Common\Collections\Collection + */ + #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)] + private Collection $holdsOnStep; + public function __construct() { $this->ccUser = new ArrayCollection(); $this->destUser = new ArrayCollection(); $this->destUserByAccessKey = new ArrayCollection(); $this->signatures = new ArrayCollection(); + $this->holdsOnStep = new ArrayCollection(); $this->accessKey = bin2hex(openssl_random_pseudo_bytes(32)); } @@ -279,6 +286,17 @@ class EntityWorkflowStep return $this->freezeAfter; } + public function isOnHoldByUser(User $user): bool + { + foreach ($this->getHoldsOnStep() as $onHold) { + if ($onHold->getByUser() === $user) { + return true; + } + } + + return false; + } + public function isWaitingForTransition(): bool { if (null !== $this->transitionAfter) { @@ -413,6 +431,11 @@ class EntityWorkflowStep return $this; } + public function getHoldsOnStep(): Collection + { + return $this->holdsOnStep; + } + #[Assert\Callback] public function validateOnCreation(ExecutionContextInterface $context, mixed $payload): void { @@ -432,4 +455,13 @@ class EntityWorkflowStep } } } + + public function addOnHold(EntityWorkflowStepHold $onHold): self + { + if (!$this->holdsOnStep->contains($onHold)) { + $this->holdsOnStep->add($onHold); + } + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepHold.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepHold.php new file mode 100644 index 000000000..3d163dfc5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepHold.php @@ -0,0 +1,54 @@ +addOnHold($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getStep(): EntityWorkflowStep + { + return $this->step; + } + + public function getByUser(): User + { + return $this->byUser; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php index 46c8bb04d..5b659d844 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php @@ -105,6 +105,11 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate return $this->state; } + /** + * @return $this + * + * @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead + */ public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature { $this->state = $state; @@ -117,6 +122,11 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate return $this->stateDate; } + /** + * @return $this + * + * @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead + */ public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature { $this->stateDate = $stateDate; @@ -129,10 +139,58 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate return $this->zoneSignatureIndex; } + /** + * @return $this + * + * @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead + */ public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature { $this->zoneSignatureIndex = $zoneSignatureIndex; return $this; } + + public function isSigned(): bool + { + return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState(); + } + + public function isPending(): bool + { + return EntityWorkflowSignatureStateEnum::PENDING == $this->getState(); + } + + /** + * Checks whether all signatures associated with a given workflow step are not pending. + * + * Iterates over each signature in the provided workflow step, and returns false if any signature + * is found to be pending. If all signatures are not pending, returns true. + * + * @param EntityWorkflowStep $step the workflow step whose signatures are to be checked + * + * @return bool true if all signatures are not pending, false otherwise + */ + public static function isAllSignatureNotPendingForStep(EntityWorkflowStep $step): bool + { + foreach ($step->getSignatures() as $signature) { + if ($signature->isPending()) { + return false; + } + } + + return true; + } + + /** + * @return 'person'|'user' + */ + public function getSignerKind(): string + { + if ($this->personSigner instanceof Person) { + return 'person'; + } + + return 'user'; + } } diff --git a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php index 81cdcd551..62282fbe9 100644 --- a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php @@ -198,6 +198,34 @@ class EntityWorkflowRepository implements ObjectRepository return $this->repository->findOneBy($criteria); } + /** + * Finds workflows that are not finalized and are older than the specified date. + * + * @param \DateTimeImmutable $olderThanDate the date to compare against + * + * @return list the list of workflow IDs that meet the criteria + */ + public function findWorkflowsWithoutFinalStepAndOlderThan(\DateTimeImmutable $olderThanDate): array + { + $qb = $this->repository->createQueryBuilder('sw'); + + $qb->select('sw.id') + // only the workflow which are not finalized + ->where('NOT EXISTS (SELECT 1 FROM chill_main_entity_workflow_step ews WHERE ews.isFinal = TRUE AND ews.entityWorkflow = sw.id)') + ->andWhere( + $qb->expr()->orX( + // only the workflow where all the last transition is older than transitionAt + ':olderThanDate > ALL (SELECT ews.transitionAt FROM chill_main_entity_workflow_step ews WHERE ews.transitionAt IS NOT NULL AND ews.entityWorkflow = sw.id)', + // or the workflow which have only the initial step, with no transition + '1 = (SELECT COUNT(ews.id) FROM chill_main_entity_workflow_step ews WHERE ews.step = :initial AND ews.transitionAt IS NULL AND ews.createdAt < :olderThanDate AND ews.entityWorkflow = sw.id)', + ) + ) + ->andWhere('sw.createdAt < :olderThanDate') + ->setParameter('olderThanDate', $olderThanDate); + + return $qb->getQuery()->getResult(); + } + public function getClassName(): string { return EntityWorkflow::class; @@ -230,7 +258,10 @@ class EntityWorkflowRepository implements ObjectRepository $qb->where( $qb->expr()->andX( - $qb->expr()->isMemberOf(':user', 'step.destUser'), + $qb->expr()->orX( + $qb->expr()->isMemberOf(':user', 'step.destUser'), + $qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'), + ), $qb->expr()->isNull('step.transitionAfter'), $qb->expr()->eq('step.isFinal', "'FALSE'") ) diff --git a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepHoldRepository.php b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepHoldRepository.php new file mode 100644 index 000000000..a925246e4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepHoldRepository.php @@ -0,0 +1,79 @@ + + */ +class EntityWorkflowStepHoldRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EntityWorkflowStepHold::class); + } + + /** + * Find an EntityWorkflowStepHold by its ID. + */ + public function findById(int $id): ?EntityWorkflowStepHold + { + return $this->find($id); + } + + /** + * Find all EntityWorkflowStepHold records. + * + * @return EntityWorkflowStepHold[] + */ + public function findAllHolds(): array + { + return $this->findAll(); + } + + /** + * Find EntityWorkflowStepHold by a specific step. + * + * @return EntityWorkflowStepHold[] + */ + public function findByStep(EntityWorkflowStep $step): array + { + return $this->findBy(['step' => $step]); + } + + /** + * Find a single EntityWorkflowStepHold by step and user. + * + * @throws NonUniqueResultException + */ + public function findOneByStepAndUser(EntityWorkflowStep $step, User $user): ?EntityWorkflowStepHold + { + try { + return $this->createQueryBuilder('e') + ->andWhere('e.step = :step') + ->andWhere('e.byUser = :user') + ->setParameter('step', $step) + ->setParameter('user', $user) + ->getQuery() + ->getSingleResult(); + } catch (NoResultException) { + return null; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss index 2523ee202..4f215859f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss @@ -31,6 +31,8 @@ // Specific templates @import './scss/notification'; +@import './scss/hover.scss'; + /* * BASE LAYOUT POSITION */ @@ -496,6 +498,7 @@ div.workflow { div.breadcrumb { display: initial; margin-bottom: 0; + margin-right: .5rem; padding-right: 0.5em; background-color: tint-color($chill-yellow, 90%); border: 1px solid $chill-yellow; diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/hover.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/hover.scss new file mode 100644 index 000000000..95fe12efc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/hover.scss @@ -0,0 +1,11 @@ + + +.row.row-hover { + padding: 0.3rem; + + &:hover { + background-color: $gray-100; + border-top: 1px solid $gray-400; + border-bottom: 1px solid $gray-400; + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss index 5158a826e..d37765e6b 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss @@ -17,6 +17,10 @@ ul.record_actions { display: inline-block; } + &.slim { + margin-bottom: 0; + } + &.column { flex-direction: column; } diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyWorkflowsTable.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyWorkflowsTable.vue index f98d7a5cb..f82e8a88c 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyWorkflowsTable.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyWorkflowsTable.vue @@ -9,13 +9,16 @@ + {{ $t('on_hold') }}
@@ -73,7 +74,8 @@ const i18n = { you_subscribed_to_all_steps: "Vous recevrez une notification à chaque étape", you_subscribed_to_final_step: "Vous recevrez une notification à l'étape finale", by: "Par", - at: "Le" + at: "Le", + on_hold: "En attente" } } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/Dev/dev.assets.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Dev/dev.assets.html.twig index 6a7c4edf0..5aafb6635 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Dev/dev.assets.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Dev/dev.assets.html.twig @@ -300,7 +300,96 @@
+

slim

+ +

Ajouter slim enlève la marge inférieure. Permet un meilleur alignement horizontal dans une row

+ +
+
+
+ Some text, ul_record_actions sans slim +
+
+
    +
  • +
+
+
+
+
+ Some text, ul_record_actions avec slim +
+
+
    +
  • +
+
+
+
+ + + <a class="btn btn-submit">Text</a> Toutes les classes btn-* de bootstrap sont fonctionnelles + +

Hover

+ +

Ajouter .row-hover sur une class .row provoque un changement de background au survol

+ +
+
+ +
+ A signé le 04/09/2024 à 13:55 +
+
+
+ +
+ +
+
+
+ +
+ A signé le 04/09/2024 à 13:57 +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ {% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig index a3e2e24b9..670d27ecc 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig @@ -40,11 +40,13 @@ {% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %}
- {% if step.transitionBy is not null %}
- {{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }} + {%- if step.transitionBy is not null -%} + {{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }} + {% else %} + {{ 'workflow.Automated transition'|trans }} + {%- endif -%}
- {% endif %}
{{ step.transitionAt|format_datetime('long', 'medium') }}
@@ -76,7 +78,11 @@

{{ 'workflow.Users allowed to apply transition'|trans }} :

    {% for u in step.destUser %} -
  • {{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
  • +
  • {{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }} + {% if entity_workflow.isOnHoldAtCurrentStep %} + {{ 'workflow.On hold'|trans }} + {% endif %} +
  • {% endfor %}
{% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig index f613b69dd..50172089c 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig @@ -1,20 +1,39 @@

{{ 'workflow.signature_zone.title'|trans }}

-
- {% for s in signatures %} -
{{ s.signer|chill_entity_render_box }}
-
-
    -
  • - {{ 'workflow.signature_zone.button_sign'|trans }} - {% if s.state is same as('signed') %} -

    {{ s.stateDate }}

    + {% for s in signatures %} +
    +
    + {% if s.signerKind == 'person' %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + isDead: s.signer.deathDate is not null + } %} + {% else %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'user', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + } %} + {% endif %} +
    +
    + {% if s.isSigned %} + {{ 'workflow.signature_zone.has_signed_statement'|trans({ 'datetime' : s.stateDate }) }} + {% else %} + {% endif %} -
  • -
-
- {% endfor %} -
+
+
+ {% endfor %}
diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig index da4d073b2..c5daff201 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig @@ -39,6 +39,9 @@

{{ handler.entityTitle(entity_workflow) }}

{{ macro.breadcrumb({'entity_workflow': entity_workflow}) }} + {% if entity_workflow.isOnHoldAtCurrentStep %} + {{ 'workflow.On hold'|trans }} + {% endif %} {% include handler_template with handler_template_data|merge({'display_action': true }) %} @@ -64,14 +67,21 @@
{% include '@ChillMain/Workflow/_comment.html.twig' %}
#}
{% include '@ChillMain/Workflow/_history.html.twig' %}
- {# useful ? - #} + {% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/list.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/list.html.twig index 5d8c39c63..5dd6dc714 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/list.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/list.html.twig @@ -69,6 +69,9 @@
{{ macro.breadcrumb(l) }} + {% if l.entity_workflow.isOnHoldAtCurrentStep %} + {{ 'workflow.On hold'|trans }} + {% endif %}
diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig index 71d1efcf1..e3087769f 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig @@ -3,24 +3,28 @@ {% if step.previous is not null %}
  • {{ 'By'|trans ~ ' : ' }} - {{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }} + {% if step.previous.transitionBy is not null %}{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}{% else %}{{ 'workflow.Automated transition'|trans }}{% endif %}
  • {{ 'Le'|trans ~ ' : ' }} {{ step.previous.transitionAt|format_datetime('short', 'short') }}
  • -
  • - {{ 'workflow.For'|trans ~ ' : ' }} - - {% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %} - -
  • -
  • - {{ 'workflow.Cc'|trans ~ ' : ' }} - - {% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %} - -
  • + {% if step.destUser|length > 0 %} +
  • + {{ 'workflow.For'|trans ~ ' : ' }} + + {% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %} + +
  • + {% endif %} + {% if step.ccUser|length > 0 %} +
  • + {{ 'workflow.Cc'|trans ~ ' : ' }} + + {% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %} + +
  • + {% endif %} {% else %}
  • {{ 'workflow.Created by'|trans ~ ' : ' }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig index 1e8469968..034b9c38a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig @@ -8,6 +8,6 @@ Vous êtes invités à valider cette étape au plus tôt. Vous pouvez visualiser le workflow sur cette page: -{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id})) }} +{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }} Cordialement, diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig index 6237cb68a..b4574567d 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig @@ -6,7 +6,7 @@ Titre du workflow: "{{ entityTitle }}". Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant: -{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey})) }} +{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey, '_locale': fr})) }} Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape. diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/EntityWorkflowNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/EntityWorkflowNormalizer.php index b1f3807ed..a84220099 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/EntityWorkflowNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/EntityWorkflowNormalizer.php @@ -45,6 +45,7 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn 'steps' => $this->normalizer->normalize($object->getStepsChained(), $format, $context), 'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), 'title' => $handler->getEntityTitle($object), + 'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), ]; } diff --git a/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowCronJob.php b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowCronJob.php new file mode 100644 index 000000000..be3dd2a5e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowCronJob.php @@ -0,0 +1,70 @@ +clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D')); + } + + public function getKey(): string + { + return self::KEY; + } + + public function run(array $lastExecutionData): ?array + { + $this->logger->info('Cronjob started: Canceling stale workflows.'); + + $olderThanDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL)); + $staleWorkflowIds = $this->workflowRepository->findWorkflowsWithoutFinalStepAndOlderThan($olderThanDate); + $lastCanceled = $lastExecutionData[self::LAST_CANCELED_WORKFLOW] ?? 0; + $processedCount = 0; + + foreach ($staleWorkflowIds as $wId) { + try { + $this->messageBus->dispatch(new CancelStaleWorkflowMessage($wId)); + $lastCanceled = max($wId, $lastCanceled); + ++$processedCount; + } catch (\Exception $e) { + $this->logger->error("Failed to dispatch CancelStaleWorkflow for ID {$wId}", ['exception' => $e]); + continue; + } + } + + $this->logger->info("Cronjob completed: {$processedCount} workflows processed."); + + return [self::LAST_CANCELED_WORKFLOW => $lastCanceled]; + } +} diff --git a/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowHandler.php b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowHandler.php new file mode 100644 index 000000000..54485dad5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowHandler.php @@ -0,0 +1,88 @@ +getWorkflowId(); + $olderThanDate = $this->clock->now()->sub(new \DateInterval(CancelStaleWorkflowCronJob::KEEP_INTERVAL)); + + $workflow = $this->workflowRepository->find($message->getWorkflowId()); + if (null === $workflow) { + $this->logger->alert('Workflow was not found!', [$workflowId]); + + return; + } + + if (false === $workflow->isStaledAt($olderThanDate)) { + $this->logger->alert('Workflow has transitioned in the meantime.', [$workflowId]); + + throw new UnrecoverableMessageHandlingException('the workflow is not staled any more'); + } + + $workflowComponent = $this->registry->get($workflow, $workflow->getWorkflowName()); + $metadataStore = $workflowComponent->getMetadataStore(); + $transitions = $workflowComponent->getEnabledTransitions($workflow); + + $transitionApplied = false; + $wasInInitialPosition = 'initial' === $workflow->getStep(); + + foreach ($transitions as $transition) { + $isFinal = $metadataStore->getMetadata('isFinal', $transition); + $isFinalPositive = $metadataStore->getMetadata('isFinalPositive', $transition); + + if ($isFinal && !$isFinalPositive) { + $dto = new WorkflowTransitionContextDTO($workflow); + $workflowComponent->apply($workflow, $transition->getName(), [ + 'context' => $dto, + 'byUser' => null, + 'transitionAt' => $this->clock->now(), + 'transition' => $transition->getName(), + ]); + $this->logger->info('EntityWorkflow has been cancelled automatically.', [$workflowId]); + $transitionApplied = true; + break; + } + } + + if (!$transitionApplied) { + $this->logger->error('No valid transition found for EntityWorkflow.', [$workflowId]); + throw new UnrecoverableMessageHandlingException(sprintf('No valid transition found for EntityWorkflow %d.', $workflowId)); + } + + if ($wasInInitialPosition) { + $this->em->remove($workflow); + } + + $this->em->flush(); + } +} diff --git a/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowMessage.php b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowMessage.php new file mode 100644 index 000000000..30d2b6ab8 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/Workflow/CancelStaleWorkflowMessage.php @@ -0,0 +1,22 @@ +workflowId; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowOnHoldControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowOnHoldControllerTest.php new file mode 100644 index 000000000..198adca5f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowOnHoldControllerTest.php @@ -0,0 +1,116 @@ +addPlaces(['initial', 'layout', 'sign']) + ->addTransition(new Transition('to_layout', 'initial', 'layout')) + ->addTransition(new Transition('to_sign', 'initial', 'sign')) + ->build(); + + $workflow = new Workflow($definition, new EntityWorkflowMarkingStore(), name: 'dummy_workflow'); + $registry = new Registry(); + $registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }); + + return $registry; + } + + public function testPutOnHoldPersistence(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy_workflow'); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn($user = new User()); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(EntityWorkflowStepHold::class)); + + $entityManager->expects($this->once()) + ->method('flush'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate') + ->with('chill_main_workflow_show', ['id' => null]) + ->willReturn('/some/url'); + + $controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator); + + $request = new Request(); + $response = $controller->putOnHold($entityWorkflow, $request); + + self::assertEquals(302, $response->getStatusCode()); + } + + public function testRemoveOnHold(): void + { + $user = new User(); + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy_workflow'); + $onHold = new EntityWorkflowStepHold($step = $entityWorkflow->getCurrentStep(), $user); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn($user); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once()) + ->method('remove') + ->with($onHold); + + $entityManager->expects($this->once()) + ->method('flush'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate') + ->with('chill_main_workflow_show', ['id' => null]) + ->willReturn('/some/url'); + + $controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator); + + $response = $controller->removeOnHold($step); + + self::assertEquals(302, $response->getStatusCode()); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/Workflow/EntityWorkflowTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/Workflow/EntityWorkflowTest.php index 4eb56f995..3241a41fc 100644 --- a/src/Bundle/ChillMainBundle/Tests/Entity/Workflow/EntityWorkflowTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Entity/Workflow/EntityWorkflowTest.php @@ -138,4 +138,31 @@ final class EntityWorkflowTest extends TestCase self::assertContains($person1, $persons); self::assertContains($person2, $persons); } + + public function testIsStaledAt(): void + { + $creationDate = new \DateTimeImmutable('2024-01-01'); + $firstStepDate = new \DateTimeImmutable('2024-01-02'); + $afterFistStep = new \DateTimeImmutable('2024-01-03'); + + $entityWorkflow = new EntityWorkflow(); + + self::assertFalse($entityWorkflow->isStaledAt($creationDate), 'an entityWorkflow with null createdAt date should never be staled at initial step'); + self::assertFalse($entityWorkflow->isStaledAt($firstStepDate), 'an entityWorkflow with null createdAt date should never be staled at initial step'); + self::assertFalse($entityWorkflow->isStaledAt($afterFistStep), 'an entityWorkflow with null createdAt date should never be staled at initial step'); + + $entityWorkflow->setCreatedAt($creationDate); + + self::assertFalse($entityWorkflow->isStaledAt($creationDate), 'an entityWorkflow with no step after initial should be staled'); + self::assertTrue($entityWorkflow->isStaledAt($firstStepDate), 'an entityWorkflow with no step after initial should be staled'); + self::assertTrue($entityWorkflow->isStaledAt($afterFistStep), 'an entityWorkflow with no step after initial should be staled'); + + // apply a first step + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('new_step', $dto, 'to_new_step', $firstStepDate); + + self::assertFalse($entityWorkflow->isStaledAt($creationDate)); + self::assertFalse($entityWorkflow->isStaledAt($firstStepDate)); + self::assertTrue($entityWorkflow->isStaledAt($afterFistStep)); + } } diff --git a/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowCronJobTest.php new file mode 100644 index 000000000..200b92099 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowCronJobTest.php @@ -0,0 +1,104 @@ +createMock(LoggerInterface::class); + + $cronJob = new CancelStaleWorkflowCronJob($this->createMock(EntityWorkflowRepository::class), $clock, $this->buildMessageBus(), $logger); + + self::assertEquals($expected, $cronJob->canRun($cronJobExecution)); + } + + /** + * @throws \DateMalformedStringException + * @throws \DateInvalidTimeZoneException + * @throws \Exception|Exception + */ + public function testRun(): void + { + $clock = new MockClock((new \DateTimeImmutable('now', new \DateTimeZone('+00:00')))->add(new \DateInterval('P120D'))); + $workflowRepository = $this->createMock(EntityWorkflowRepository::class); + + $workflowRepository->method('findWorkflowsWithoutFinalStepAndOlderThan')->willReturn([1, 3, 2]); + $messageBus = $this->buildMessageBus(true); + + $cronJob = new CancelStaleWorkflowCronJob($workflowRepository, $clock, $messageBus, new NullLogger()); + + $results = $cronJob->run([]); + + // Assert the result has the last canceled workflow ID + self::assertArrayHasKey('last-canceled-workflow-id', $results); + self::assertEquals(3, $results['last-canceled-workflow-id']); + } + + /** + * @throws \Exception + */ + public static function buildTestCanRunData(): iterable + { + yield [ + (new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:00', new \DateTimeZone('+00:00'))), + true, + ]; + + yield [ + (new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-30 23:59:59', new \DateTimeZone('+00:00'))), + true, + ]; + + yield [ + (new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:01', new \DateTimeZone('+00:00'))), + false, + ]; + } + + private function buildMessageBus(bool $expectDispatchAtLeastOnce = false): MessageBusInterface + { + $messageBus = $this->createMock(MessageBusInterface::class); + + $methodDispatch = match ($expectDispatchAtLeastOnce) { + true => $messageBus->expects($this->atLeastOnce())->method('dispatch')->with($this->isInstanceOf(CancelStaleWorkflowMessage::class)), + false => $messageBus->method('dispatch'), + }; + + $methodDispatch->willReturnCallback(fn (CancelStaleWorkflowMessage $message) => new Envelope($message)); + + return $messageBus; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowHandlerTest.php b/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowHandlerTest.php new file mode 100644 index 000000000..b6cac68de --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Services/Workflow/CancelStaleWorkflowHandlerTest.php @@ -0,0 +1,161 @@ +setWorkflowName('dummy_workflow'); + $workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01')); + $workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User()); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->flush()->shouldBeCalled(); + $em->remove($workflow)->shouldNotBeCalled(); + + $handler = $this->buildHandler($workflow, $em->reveal(), $clock); + + $handler(new CancelStaleWorkflowMessage(1)); + + self::assertEquals('canceled', $workflow->getStep()); + self::assertCount(3, $workflow->getSteps()); + } + + public function testWorkflowNotInStaledHandlerIsUnrecoverable(): void + { + $this->expectException(UnrecoverableMessageHandlingException::class); + + $clock = new MockClock('2024-01-01'); + $daysAgos = new \DateTimeImmutable('2023-12-31'); + + $workflow = new EntityWorkflow(); + $workflow->setWorkflowName('dummy_workflow'); + $workflow->setCreatedAt(new \DateTimeImmutable('2023-12-31')); + $workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User()); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->flush()->shouldNotBeCalled(); + $em->remove($workflow)->shouldNotBeCalled(); + + $handler = $this->buildHandler($workflow, $em->reveal(), $clock); + + $handler(new CancelStaleWorkflowMessage(1)); + + self::assertEquals('canceled', $workflow->getStep()); + self::assertCount(3, $workflow->getSteps()); + } + + public function testWorkflowStaledInInitialStateIsCompletelyRemoved(): void + { + $clock = new MockClock('2024-01-01'); + + $workflow = new EntityWorkflow(); + $workflow->setWorkflowName('dummy_workflow'); + $workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01')); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->flush()->shouldBeCalled(); + $em->remove($workflow)->shouldBeCalled(); + + $handler = $this->buildHandler($workflow, $em->reveal(), $clock); + + $handler(new CancelStaleWorkflowMessage(1)); + + self::assertEquals('canceled', $workflow->getStep()); + self::assertCount(2, $workflow->getSteps()); + } + + private function buildHandler( + EntityWorkflow $entityWorkflow, + EntityManagerInterface $entityManager, + ClockInterface $clock, + ): CancelStaleWorkflowHandler { + // set an id for the workflow + $reflection = new \ReflectionClass($entityWorkflow); + $reflection->getProperty('id')->setValue($entityWorkflow, 1); + + $repository = $this->prophesize(EntityWorkflowRepository::class); + $repository->find(1)->willReturn($entityWorkflow); + + return new CancelStaleWorkflowHandler($repository->reveal(), $this->buildRegistry(), $entityManager, new NullLogger(), $clock); + } + + private function buildRegistry(): Registry + { + $definitionBuilder = new DefinitionBuilder(); + + $definitionBuilder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'step1', 'canceled', 'final']) + ->addTransition(new Transition('to_step1', 'initial', 'step1')) + ->addTransition($cancelInit = new Transition('cancel', 'initial', 'canceled')) + ->addTransition($finalizeInit = new Transition('finalize', 'initial', 'final')) + ->addTransition($cancelStep1 = new Transition('cancel', 'step1', 'canceled')) + ->addTransition($finalizeStep1 = new Transition('finalize', 'step1', 'final')); + + $transitionStorage = new \SplObjectStorage(); + $transitionStorage->attach($finalizeInit, ['isFinal' => true, 'isFinalPositive' => true]); + $transitionStorage->attach($cancelInit, ['isFinal' => true, 'isFinalPositive' => false]); + $transitionStorage->attach($finalizeStep1, ['isFinal' => true, 'isFinalPositive' => true]); + $transitionStorage->attach($cancelStep1, ['isFinal' => true, 'isFinalPositive' => false]); + + $definitionBuilder->setMetadataStore(new InMemoryMetadataStore([], [], $transitionStorage)); + $workflow = new Workflow($definitionBuilder->build(), new EntityWorkflowMarkingStore(), null, 'dummy_workflow'); + $supports = + new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }; + + + $registry = new Registry(); + $registry->addWorkflow($workflow, $supports); + + return $registry; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php new file mode 100644 index 000000000..bccd6cf9e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php @@ -0,0 +1,168 @@ +setInitialPlaces(['initial']) + ->addPlaces(['initial', 'intermediate', 'step1', 'step2', 'step3']) + ->addTransition(new Transition('intermediate', 'initial', 'intermediate')) + ->addTransition($transition1 = new Transition('transition1', 'intermediate', 'step1')) + ->addTransition($transition2 = new Transition('transition2', 'intermediate', 'step2')) + ->addTransition($transition3 = new Transition('transition3', 'intermediate', 'step3')) + ; + + $transitionMetadata = new \SplObjectStorage(); + $transitionMetadata->attach($transition1, ['transitionGuard' => 'only-dest']); + $transitionMetadata->attach($transition2, ['transitionGuard' => 'only-dest+system']); + $transitionMetadata->attach($transition3, ['transitionGuard' => 'system']); + + $builder->setMetadataStore(new InMemoryMetadataStore(transitionsMetadata: $transitionMetadata)); + + if (null !== $eventSubscriber) { + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($eventSubscriber); + } + + $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher ?? null, 'dummy'); + + $registry = new Registry(); + $registry->addWorkflow( + $workflow, + new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + } + ); + + return $registry; + } + + /** + * @dataProvider provideBlockingTransition + */ + public function testTransitionGuardBlocked(EntityWorkflow $entityWorkflow, string $transition, ?User $user, string $uuid): void + { + $userRender = $this->prophesize(UserRender::class); + $userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string'); + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn($user); + + $transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal()); + $registry = self::buildRegistry($transitionGuard); + + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $context = new WorkflowTransitionContextDTO($entityWorkflow); + + self::expectException(NotEnabledTransitionException::class); + try { + $workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]); + } catch (NotEnabledTransitionException $e) { + $list = $e->getTransitionBlockerList(); + + self::assertEquals(1, $list->count()); + $list = iterator_to_array($list->getIterator()); + self::assertEquals($uuid, $list[0]->getCode()); + + throw $e; + } + } + + /** + * @dataProvider provideValidTransition + */ + public function testTransitionGuardValid(EntityWorkflow $entityWorkflow, string $transition, ?User $user, string $newStep): void + { + $userRender = $this->prophesize(UserRender::class); + $userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string'); + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn($user); + + $transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal()); + $registry = self::buildRegistry($transitionGuard); + + $workflow = $registry->get($entityWorkflow, 'dummy'); + $context = new WorkflowTransitionContextDTO($entityWorkflow); + + $workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals($newStep, $entityWorkflow->getStep()); + } + + public static function provideBlockingTransition(): iterable + { + yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc']; + yield [self::buildEntityWorkflow([]), 'transition1', null, 'd9e39a18-704c-11ef-b235-8fe0619caee7']; + yield [self::buildEntityWorkflow([new User()]), 'transition1', null, 'd9e39a18-704c-11ef-b235-8fe0619caee7']; + yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb']; + } + + public static function provideValidTransition(): iterable + { + yield [self::buildEntityWorkflow([$u = new User()]), 'transition1', $u, 'step1']; + yield [self::buildEntityWorkflow([$u = new User()]), 'transition2', $u, 'step2']; + yield [self::buildEntityWorkflow([new User()]), 'transition2', null, 'step2']; + yield [self::buildEntityWorkflow([]), 'transition2', null, 'step2']; + yield [self::buildEntityWorkflow([new User()]), 'transition3', null, 'step3']; + yield [self::buildEntityWorkflow([]), 'transition3', null, 'step3']; + } + + public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow + { + $registry = self::buildRegistry(null); + $baseContext = ['transition' => 'intermediate', 'transitionAt' => new \DateTimeImmutable()]; + + // test a user not is destination is blocked + $entityWorkflow = new EntityWorkflow(); + $workflow = $registry->get($entityWorkflow, 'dummy'); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers = $futureDestUsers; + $workflow->apply($entityWorkflow, 'intermediate', ['context' => $dto, ...$baseContext]); + + return $entityWorkflow; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/SignatureStepStateChangerTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/SignatureStepStateChangerTest.php new file mode 100644 index 000000000..5eb82bab9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/SignatureStepStateChangerTest.php @@ -0,0 +1,145 @@ +setWorkflowName('dummy'); + $registry = $this->buildRegistry(); + $workflow = $registry->get($entityWorkflow, 'dummy'); + $clock = new MockClock(); + $user = new User(); + $changer = new SignatureStepStateChanger($registry, $clock); + + // move it to signature + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures = [new Person(), new Person()]; + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => $clock->now(), + 'byUser' => $user, 'transition' => 'to_signature']); + + // get the signature created + $signatures = $entityWorkflow->getCurrentStep()->getSignatures(); + + if (2 !== count($signatures)) { + throw new \LogicException('there should have 2 signatures at this step'); + } + + // we mark the first signature as signed + $changer->markSignatureAsSigned($signatures[0], 1); + + self::assertEquals('signature', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step'); + self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState()); + self::assertEquals(1, $signatures[0]->getZoneSignatureIndex()); + self::assertNotNull($signatures[0]->getStateDate()); + + + // we mark the second signature as signed + $changer->markSignatureAsSigned($signatures[1], 2); + self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[1]->getState()); + self::assertEquals('post-signature', $entityWorkflow->getStep(), 'the entity workflow step should be post-signature'); + self::assertContains($user, $entityWorkflow->getCurrentStep()->getAllDestUser()); + self::assertEquals(2, $signatures[1]->getZoneSignatureIndex()); + self::assertNotNull($signatures[1]->getStateDate()); + } + + public function testMarkSignatureAsSignedScenarioWithoutRequiredMetadata() + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + $registry = $this->buildRegistry(); + $workflow = $registry->get($entityWorkflow, 'dummy'); + $clock = new MockClock(); + $user = new User(); + $changer = new SignatureStepStateChanger($registry, $clock); + + // move it to signature + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures = [new Person()]; + $workflow->apply($entityWorkflow, 'to_signature-without-metadata', ['context' => $dto, 'transitionAt' => $clock->now(), + 'byUser' => $user, 'transition' => 'to_signature-without-metadata']); + + // get the signature created + $signatures = $entityWorkflow->getCurrentStep()->getSignatures(); + + if (1 !== count($signatures)) { + throw new \LogicException('there should have 2 signatures at this step'); + } + + // we mark the first signature as signed + $changer->markSignatureAsSigned($signatures[0], 1); + + self::assertEquals('signature-without-metadata', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step'); + self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState()); + self::assertEquals(1, $signatures[0]->getZoneSignatureIndex()); + self::assertNotNull($signatures[0]->getStateDate()); + } + + private function buildRegistry(): Registry + { + $builder = new DefinitionBuilder(); + $builder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'signature', 'signature-without-metadata', 'post-signature']) + ->addTransition(new Transition('to_signature', 'initial', 'signature')) + ->addTransition(new Transition('to_signature-without-metadata', 'initial', 'signature-without-metadata')) + ->addTransition(new Transition('to_post-signature', 'signature', 'post-signature')) + ->addTransition(new Transition('to_post-signature_2', 'signature-without-metadata', 'post-signature')) + ; + + $metadata = new InMemoryMetadataStore( + [], + [ + 'signature' => ['onSignatureCompleted' => ['transitionName' => 'to_post-signature']], + ] + ); + $builder->setMetadataStore($metadata); + + $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy'); + $registry = new Registry(); + $registry->addWorkflow( + $workflow, + new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + } + ); + + return $registry; + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php new file mode 100644 index 000000000..25ed21e98 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php @@ -0,0 +1,105 @@ + [ + ['guardEntityWorkflow', 0], + ], + ]; + } + + public function guardEntityWorkflow(GuardEvent $event) + { + if (!$event->getSubject() instanceof EntityWorkflow) { + return; + } + + /** @var EntityWorkflow $entityWorkflow */ + $entityWorkflow = $event->getSubject(); + + if ($entityWorkflow->isFinal()) { + $event->addTransitionBlocker( + new TransitionBlocker( + 'workflow.The workflow is finalized', + 'd6306280-7535-11ec-a40d-1f7bee26e2c0' + ) + ); + + return; + } + + $user = $this->security->getUser(); + $metadata = $event->getWorkflow()->getMetadataStore()->getTransitionMetadata($event->getTransition()); + $systemTransitions = explode('+', $metadata['transitionGuard'] ?? 'only-dest'); + + if (null === $user) { + if (in_array('system', $systemTransitions, true)) { + // it is safe to apply this transition + return; + } + + $event->addTransitionBlocker( + new TransitionBlocker( + 'workflow.Transition is not allowed for system', + 'd9e39a18-704c-11ef-b235-8fe0619caee7' + ) + ); + + return; + } + + // for users + if (!in_array('only-dest', $systemTransitions, true)) { + $event->addTransitionBlocker( + new TransitionBlocker( + 'workflow.Only system can apply this transition', + '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb' + ) + ); + } + + if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($user)) { + if ($event->getMarking()->has('initial')) { + return; + } + + $event->addTransitionBlocker(new TransitionBlocker( + 'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%', + 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc', + [ + '%users%' => implode( + ', ', + $entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray() + ), + ] + )); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowTransitionEventSubscriber.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowTransitionEventSubscriber.php index fe489c59d..8903d79eb 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowTransitionEventSubscriber.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowTransitionEventSubscriber.php @@ -13,20 +13,16 @@ namespace Chill\MainBundle\Workflow\EventSubscriber; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; -use Chill\MainBundle\Templating\Entity\UserRender; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\Event\Event; -use Symfony\Component\Workflow\Event\GuardEvent; -use Symfony\Component\Workflow\TransitionBlocker; final readonly class EntityWorkflowTransitionEventSubscriber implements EventSubscriberInterface { public function __construct( private LoggerInterface $chillLogger, private Security $security, - private UserRender $userRender, ) {} public static function getSubscribedEvents(): array @@ -36,48 +32,9 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub 'workflow.completed' => [ ['markAsFinal', 2048], ], - 'workflow.guard' => [ - ['guardEntityWorkflow', 0], - ], ]; } - public function guardEntityWorkflow(GuardEvent $event) - { - if (!$event->getSubject() instanceof EntityWorkflow) { - return; - } - - /** @var EntityWorkflow $entityWorkflow */ - $entityWorkflow = $event->getSubject(); - - if ($entityWorkflow->isFinal()) { - $event->addTransitionBlocker( - new TransitionBlocker( - 'workflow.The workflow is finalized', - 'd6306280-7535-11ec-a40d-1f7bee26e2c0' - ) - ); - - return; - } - - if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($this->security->getUser())) { - if (!$event->getMarking()->has('initial')) { - $event->addTransitionBlocker(new TransitionBlocker( - 'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%', - 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc', - [ - '%users%' => implode( - ', ', - $entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray() - ), - ] - )); - } - } - } - public function markAsFinal(Event $event): void { // NOTE: it is not possible to move this method to the marking store, because @@ -109,11 +66,13 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub /** @var EntityWorkflow $entityWorkflow */ $entityWorkflow = $event->getSubject(); + $user = $this->security->getUser(); + $this->chillLogger->info('[workflow] apply transition on entityWorkflow', [ 'relatedEntityClass' => $entityWorkflow->getRelatedEntityClass(), 'relatedEntityId' => $entityWorkflow->getRelatedEntityId(), 'transition' => $event->getTransition()->getName(), - 'by_user' => $this->security->getUser(), + 'by_user' => $user instanceof User ? $user->getId() : (string) $user?->getUserIdentifier(), 'entityWorkflow' => $entityWorkflow->getId(), ]); } diff --git a/src/Bundle/ChillMainBundle/Workflow/SignatureStepStateChanger.php b/src/Bundle/ChillMainBundle/Workflow/SignatureStepStateChanger.php new file mode 100644 index 000000000..3e2871810 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/SignatureStepStateChanger.php @@ -0,0 +1,112 @@ +setState(EntityWorkflowSignatureStateEnum::SIGNED) + ->setZoneSignatureIndex($atIndex) + ->setStateDate($this->clock->now()) + ; + + $this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]); + + if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) { + $this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]); + + return; + } + + $this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]); + + $entityWorkflow = $signature->getStep()->getEntityWorkflow(); + $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $metadataStore = $workflow->getMetadataStore(); + + // find a transition + $marking = $workflow->getMarking($entityWorkflow); + $places = $marking->getPlaces(); + + $transition = null; + foreach ($places as $place => $int) { + $metadata = $metadataStore->getPlaceMetadata($place); + if (array_key_exists('onSignatureCompleted', $metadata)) { + $transition = $metadata['onSignatureCompleted']['transitionName']; + } + } + + if (null === $transition) { + $this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]); + + return; + } + + $previousUser = $this->getPreviousSender($signature->getStep()); + + if (null === $previousUser) { + $this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]); + + return; + } + + $transitionDto = new WorkflowTransitionContextDTO($entityWorkflow); + $transitionDto->futureDestUsers[] = $previousUser; + + $workflow->apply($entityWorkflow, $transition, [ + 'context' => $transitionDto, + 'transitionAt' => $this->clock->now(), + 'transition' => $transition, + ]); + + $this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]); + } + + private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User + { + $stepsChained = $entityWorkflowStep->getEntityWorkflow()->getStepsChained(); + + foreach ($stepsChained as $stepChained) { + if ($stepChained === $entityWorkflowStep) { + if (null === $previous = $stepChained->getPrevious()) { + return null; + } + + if (null !== $previousUser = $previous->getTransitionBy()) { + return $previousUser; + } + + return $this->getPreviousSender($previous); + } + } + + throw new \LogicException('no same step found'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240807123801.php b/src/Bundle/ChillMainBundle/migrations/Version20240807123801.php new file mode 100644 index 000000000..1e95bd359 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240807123801.php @@ -0,0 +1,46 @@ +addSql('CREATE SEQUENCE chill_main_workflow_entity_step_hold_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_main_workflow_entity_step_hold (id INT NOT NULL, step_id INT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT NOT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_1BE2E7C73B21E9C ON chill_main_workflow_entity_step_hold (step_id)'); + $this->addSql('CREATE INDEX IDX_1BE2E7CD23C0240 ON chill_main_workflow_entity_step_hold (byUser_id)'); + $this->addSql('CREATE INDEX IDX_1BE2E7C3174800F ON chill_main_workflow_entity_step_hold (createdBy_id)'); + $this->addSql('CREATE UNIQUE INDEX chill_main_workflow_hold_unique_idx ON chill_main_workflow_entity_step_hold (step_id, byUser_id)'); + $this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_step_hold.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C73B21E9C FOREIGN KEY (step_id) REFERENCES chill_main_workflow_entity_step (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7CD23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_main_workflow_entity_step_hold_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C73B21E9C'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7CD23C0240'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C3174800F'); + $this->addSql('DROP TABLE chill_main_workflow_entity_step_hold'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 96b2edd98..a0753f7a6 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -45,6 +45,9 @@ workflow: few {# workflows} other {# workflows} } + signature_zone: + has_signed_statement: 'A signé le {datetime, date, short} à {datetime, time, short}' + duration: minute: >- diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 9a90b7fa1..0c0fce51c 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -527,6 +527,10 @@ workflow: Access link copied: Lien d'accès copié This link grant any user to apply a transition: Le lien d'accès suivant permet d'appliquer une transition The workflow may be accssed through this link: Une transition peut être appliquée sur ce workflow grâce au lien d'accès suivant + Put on hold: Mettre en attente + Remove hold: Enlever la mise en attente + On hold: En attente + Automated transition: Transition automatique signature_zone: title: Appliquer les signatures électroniques @@ -541,6 +545,7 @@ workflow: user signature: Selectionner utilisateur pour signer persons: Usagers user: Utilisateur + already_signed_alert: La signature a déjà été appliquée Subscribe final: Recevoir une notification à l'étape finale diff --git a/src/Bundle/ChillWopiBundle/src/Controller/Convert.php b/src/Bundle/ChillWopiBundle/src/Controller/Convert.php deleted file mode 100644 index 16be18803..000000000 --- a/src/Bundle/ChillWopiBundle/src/Controller/Convert.php +++ /dev/null @@ -1,97 +0,0 @@ -collaboraDomain = $parameters->get('wopi')['server']; - } - - public function __invoke(StoredObject $storedObject): Response - { - if (!$this->security->getUser() instanceof User) { - throw new AccessDeniedHttpException('User must be authenticated'); - } - - $content = $this->storedObjectManager->read($storedObject); - $query = []; - if (null !== $request = $this->requestStack->getCurrentRequest()) { - $query['lang'] = $request->getLocale(); - } - - try { - $url = sprintf('%s/cool/convert-to/pdf', $this->collaboraDomain); - $form = new FormDataPart([ - 'data' => new DataPart($content, $storedObject->getUuid()->toString(), $storedObject->getType()), - ]); - $response = $this->httpClient->request('POST', $url, [ - 'headers' => $form->getPreparedHeaders()->toArray(), - 'query' => $query, - 'body' => $form->bodyToString(), - 'timeout' => 10, - ]); - - return new Response($response->getContent(), Response::HTTP_OK, [ - 'Content-Type' => 'application/pdf', - ]); - } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $exception) { - return $this->onConversionFailed($url, $exception->getResponse()); - } - } - - private function onConversionFailed(string $url, ResponseInterface $response): JsonResponse - { - $this->logger->error(self::LOG_PREFIX.' could not convert document', [ - 'response_status' => $response->getStatusCode(), - 'message' => $response->getContent(false), - 'server' => $this->collaboraDomain, - 'url' => $url, - ]); - - return new JsonResponse(['message' => 'conversion failed : '.$response->getContent(false)], Response::HTTP_SERVICE_UNAVAILABLE); - } -} diff --git a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php new file mode 100644 index 000000000..86a624e4b --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php @@ -0,0 +1,57 @@ +security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) { + throw new AccessDeniedHttpException('User must be authenticated'); + } + + $content = $this->storedObjectManager->read($storedObject); + $lang = $request->getLocale(); + + try { + return new Response($this->wopiConverter->convert($lang, $content, $storedObject->getType()), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + ]); + } catch (\RuntimeException $exception) { + $this->logger->alert(self::LOG_PREFIX.'Could not convert document', ['message' => $exception->getMessage(), 'exception', $exception->getTraceAsString()]); + + return new Response('convert server not available', Response::HTTP_SERVICE_UNAVAILABLE); + } + } +} diff --git a/src/Bundle/ChillWopiBundle/src/Resources/config/routes/routes.php b/src/Bundle/ChillWopiBundle/src/Resources/config/routes/routes.php index 2272c9efd..62837a3ce 100644 --- a/src/Bundle/ChillWopiBundle/src/Resources/config/routes/routes.php +++ b/src/Bundle/ChillWopiBundle/src/Resources/config/routes/routes.php @@ -19,5 +19,5 @@ return static function (RoutingConfigurator $routes) { $routes ->add('chill_wopi_object_convert', '/convert/{uuid}') - ->controller(Chill\WopiBundle\Controller\Convert::class); + ->controller(Chill\WopiBundle\Controller\ConvertController::class); }; diff --git a/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php new file mode 100644 index 000000000..ded9b3188 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php @@ -0,0 +1,69 @@ +collaboraDomain = $parameters->get('wopi')['server']; + } + + public function convert(string $lang, string $content, string $contentType, $convertTo = 'pdf'): string + { + try { + $url = sprintf('%s/cool/convert-to/%s', $this->collaboraDomain, $convertTo); + + $form = new FormDataPart([ + 'data' => new DataPart($content, uniqid('temp-file-'), contentType: $contentType), + ]); + $response = $this->httpClient->request('POST', $url, [ + 'headers' => $form->getPreparedHeaders()->toArray(), + 'query' => ['lang' => $lang], + 'body' => $form->bodyToString(), + 'timeout' => 10, + ]); + + if (200 === $response->getStatusCode()) { + $this->logger->info(self::LOG_PREFIX.'document converted successfully', ['size' => strlen($content)]); + } + + return $response->getContent(); + } catch (ClientExceptionInterface $e) { + throw new \LogicException('no correct request to collabora online', previous: $e); + } catch (RedirectionExceptionInterface $e) { + throw new \RuntimeException('no redirection expected', previous: $e); + } catch (ServerExceptionInterface|TransportExceptionInterface $e) { + throw new \RuntimeException('error while converting document', previous: $e); + } + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php new file mode 100644 index 000000000..9a529df6b --- /dev/null +++ b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php @@ -0,0 +1,93 @@ +registerVersion(type: 'application/vnd.oasis.opendocument.text'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $storeManager = $this->prophesize(StoredObjectManagerInterface::class); + $storeManager->read($storedObject)->willReturn('content'); + + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willThrow(new \RuntimeException()); + + $controller = new ConvertController( + $security->reveal(), + $storeManager->reveal(), + $wopiConverter->reveal(), + new NullLogger(), + ); + + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); + + $this->assertNotEquals(200, $response->getStatusCode()); + } + + public function testEverythingWentFine(): void + { + $storedObject = new StoredObject(); + $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $storeManager = $this->prophesize(StoredObjectManagerInterface::class); + $storeManager->read($storedObject)->willReturn('content'); + + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willReturn('1234'); + + $controller = new ConvertController( + $security->reveal(), + $storeManager->reveal(), + $wopiConverter->reveal(), + new NullLogger(), + ); + + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('1234', $response->getContent()); + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertTest.php b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertTest.php deleted file mode 100644 index 2bc3f4769..000000000 --- a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertTest.php +++ /dev/null @@ -1,105 +0,0 @@ -setType('application/vnd.oasis.opendocument.text'); - - $httpClient = new MockHttpClient([ - new MockResponse('not authorized', ['http_code' => 401]), - ], 'http://collabora:9980'); - - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); - - $storeManager = $this->prophesize(StoredObjectManagerInterface::class); - $storeManager->read($storedObject)->willReturn('content'); - - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); - - $convert = new Convert( - $httpClient, - $this->makeRequestStack(), - $security->reveal(), - $storeManager->reveal(), - new NullLogger(), - $parameterBag - ); - - $response = $convert($storedObject); - - $this->assertNotEquals(200, $response->getStatusCode()); - } - - public function testEverythingWentFine(): void - { - $storedObject = (new StoredObject())->setType('application/vnd.oasis.opendocument.text'); - - $httpClient = new MockHttpClient([ - new MockResponse('1234', ['http_code' => 200]), - ], 'http://collabora:9980'); - - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); - - $storeManager = $this->prophesize(StoredObjectManagerInterface::class); - $storeManager->read($storedObject)->willReturn('content'); - - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); - - $convert = new Convert( - $httpClient, - $this->makeRequestStack(), - $security->reveal(), - $storeManager->reveal(), - new NullLogger(), - $parameterBag - ); - - $response = $convert($storedObject); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('1234', $response->getContent()); - } - - private function makeRequestStack(): RequestStack - { - $requestStack = new RequestStack(); - $requestStack->push(new Request()); - - return $requestStack; - } -} diff --git a/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php new file mode 100644 index 000000000..317932ea5 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php @@ -0,0 +1,63 @@ + ['server' => $_ENV['EDITOR_SERVER']], + ]); + + $converter = new WopiConverter($client, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', $content, 'application/vnd.oasis.opendocument.text'); + + self::assertIsString($actual); + } + + public function testConvertToPdfWithMock(): void + { + $httpClient = new MockHttpClient([ + new MockResponse('1234', ['http_code' => 200]), + ], 'http://collabora:9980'); + $parameters = new ParameterBag([ + 'wopi' => ['server' => 'http://collabora:9980'], + ]); + + $converter = new WopiConverter($httpClient, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', 'content-string', 'application/vnd.oasis.opendocument.text'); + + self::assertEquals('1234', $actual); + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt new file mode 100644 index 000000000..b6f644408 Binary files /dev/null and b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt differ diff --git a/tests/app/config/packages/wopi.yaml b/tests/app/config/packages/wopi.yaml new file mode 100644 index 000000000..e02694625 --- /dev/null +++ b/tests/app/config/packages/wopi.yaml @@ -0,0 +1,2 @@ +wopi: + server: "%env(resolve:EDITOR_SERVER)%"