From 9a9d14eb5a5d851a7fb77ccad957e83958bb746b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 2 Oct 2024 13:13:26 +0200 Subject: [PATCH 01/37] Enhance behaviour of duplicating storedObject to keep only the last "kept before conversion" version if any Enhance the duplication service to selectively handle versions tagged with "KEEP_BEFORE_CONVERSION". Modify StoredObject to support retrieval and checking of such versions. Add relevant test cases to validate this behavior. --- .../Entity/StoredObject.php | 65 +++++++++++++++ .../Service/StoredObjectDuplicate.php | 13 ++- .../Tests/Entity/StoredObjectTest.php | 25 ++++++ .../Service/StoredObjectDuplicateTest.php | 82 +++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index c6d9e362c..06c2e5bd3 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -18,6 +18,8 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Order; +use Doctrine\Common\Collections\ReadableCollection; use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; @@ -257,11 +259,33 @@ class StoredObject implements Document, TrackCreationInterface return $this->template; } + /** + * @return Selectable&Collection + */ public function getVersions(): Collection&Selectable { return $this->versions; } + /** + * Retrieves versions sorted by a given order. + * + * @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending + * + * @return readableCollection&Selectable The ordered collection of versions + */ + public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable + { + $versions = $this->getVersions()->toArray(); + + match ($order) { + 'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()), + 'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()), + }; + + return new ArrayCollection($versions); + } + public function hasCurrentVersion(): bool { return null !== $this->getCurrentVersion(); @@ -272,6 +296,47 @@ class StoredObject implements Document, TrackCreationInterface return null !== $this->template; } + /** + * Checks if there is a version kept before conversion. + * + * @return bool true if a version is kept before conversion, false otherwise + */ + public function hasKeptBeforeConversionVersion(): bool + { + foreach ($this->getVersions() as $version) { + foreach ($version->getPointInTimes() as $pointInTime) { + if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) { + return true; + } + } + } + + return false; + } + + /** + * Retrieves the last version of the stored object that was kept before conversion. + * + * This method iterates through the ordered versions and their respective points + * in time to find the most recent version that has a point in time with the reason + * 'KEEP_BEFORE_CONVERSION'. + * + * @return StoredObjectVersion|null the version that was kept before conversion, + * or null if not found + */ + public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion + { + foreach ($this->getVersionsOrdered('DESC') as $version) { + foreach ($version->getPointInTimes() as $pointInTime) { + if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) { + return $version; + } + } + } + + return null; + } + public function setTemplate(?DocGeneratorTemplate $template): StoredObject { $this->template = $template; diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php index b1c7e7a87..f49a147d5 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php @@ -22,9 +22,18 @@ class StoredObjectDuplicate { public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {} - public function duplicate(StoredObject|StoredObjectVersion $from): StoredObject + public function duplicate(StoredObject|StoredObjectVersion $from, bool $onlyLastKeptBeforeConversionVersion = true): StoredObject { - $fromVersion = $from instanceof StoredObjectVersion ? $from : $from->getCurrentVersion(); + $storedObject = $from instanceof StoredObjectVersion ? $from->getStoredObject() : $from; + + $fromVersion = match ($storedObject->hasKeptBeforeConversionVersion() && $onlyLastKeptBeforeConversionVersion) { + true => $from->getLastKeptBeforeConversionVersion(), + false => $storedObject->getCurrentVersion(), + }; + + if (null === $fromVersion) { + throw new \UnexpectedValueException('could not find a version to restore'); + } $oldContent = $this->storedObjectManager->read($fromVersion); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php index 5bcfed16f..b501fe3b6 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php @@ -12,6 +12,8 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Tests\Entity; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; +use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** @@ -54,4 +56,27 @@ class StoredObjectTest extends KernelTestCase self::assertNotSame($firstVersion, $version); } + + public function testHasKeptBeforeConversionVersion(): void + { + $storedObject = new StoredObject(); + $version1 = $storedObject->registerVersion(); + + self::assertFalse($storedObject->hasKeptBeforeConversionVersion()); + + // add a point in time without the correct version + new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BY_USER); + + self::assertFalse($storedObject->hasKeptBeforeConversionVersion()); + self::assertNull($storedObject->getLastKeptBeforeConversionVersion()); + + new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + + self::assertTrue($storedObject->hasKeptBeforeConversionVersion()); + // add a second version + $version2 = $storedObject->registerVersion(); + new StoredObjectPointInTime($version2, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + + self::assertSame($version2, $storedObject->getLastKeptBeforeConversionVersion()); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php index 2e9da0bab..11cb00592 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php @@ -12,9 +12,13 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Tests\Service; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; +use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; use Chill\DocStoreBundle\Service\StoredObjectDuplicate; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; /** @@ -24,9 +28,13 @@ use Psr\Log\NullLogger; */ class StoredObjectDuplicateTest extends TestCase { + use ProphecyTrait; + public function testDuplicateHappyScenario(): void { $storedObject = new StoredObject(); + // we create multiple version, we want the last to be duplicated + $storedObject->registerVersion(type: 'application/test'); $version = $storedObject->registerVersion(type: $type = 'application/test'); $manager = $this->createMock(StoredObjectManagerInterface::class); @@ -45,4 +53,78 @@ class StoredObjectDuplicateTest extends TestCase self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom()); self::assertSame($version, $actual->getCurrentVersion()->getCreatedFrom()); } + + public function testDuplicateWithKeptVersion(): void + { + $storedObject = new StoredObject(); + // we create two versions for stored object + // the first one is "kept before conversion", and that one should + // be duplicated, not the second one + $version1 = $storedObject->registerVersion(type: $type = 'application/test'); + new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $version2 = $storedObject->registerVersion(type: $type = 'application/test'); + + $manager = $this->prophesize(StoredObjectManagerInterface::class); + + // we create both possibilities for the method "read" + $manager->read($version1)->willReturn('1234'); + $manager->read($version2)->willReturn('4567'); + + // we create the write method, and check that it is called with the content from version1, not version2 + $manager->write(Argument::type(StoredObject::class), '1234', 'application/test') + ->shouldBeCalled() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; // args are ordered by key, so the first one is the stored object... + $type = $args[2]; // and the last one is the string $type + + return $storedObject->registerVersion(type: $type); + }); + + // we create the service which will duplicate things + $storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger()); + + $actual = $storedObjectDuplicate->duplicate($storedObject); + + self::assertNotNull($actual->getCurrentVersion()); + self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom()); + self::assertSame($version1, $actual->getCurrentVersion()->getCreatedFrom()); + } + + public function testDuplicateWithKeptVersionButWeWantToDuplicateTheLastOne(): void + { + $storedObject = new StoredObject(); + // we create two versions for stored object + // the first one is "kept before conversion", and that one should + // be duplicated, not the second one + $version1 = $storedObject->registerVersion(type: $type = 'application/test'); + new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $version2 = $storedObject->registerVersion(type: $type = 'application/test'); + + $manager = $this->prophesize(StoredObjectManagerInterface::class); + + // we create both possibilities for the method "read" + $manager->read($version1)->willReturn('1234'); + $manager->read($version2)->willReturn('4567'); + + // we create the write method, and check that it is called with the content from version1, not version2 + $manager->write(Argument::type(StoredObject::class), '4567', 'application/test') + ->shouldBeCalled() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; // args are ordered by key, so the first one is the stored object... + $type = $args[2]; // and the last one is the string $type + + return $storedObject->registerVersion(type: $type); + }); + + // we create the service which will duplicate things + $storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger()); + + $actual = $storedObjectDuplicate->duplicate($storedObject, false); + + self::assertNotNull($actual->getCurrentVersion()); + self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom()); + self::assertSame($version2, $actual->getCurrentVersion()->getCreatedFrom()); + } } From 2213f6f429cf452e2d9ec16298dbd028dc1ceee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 3 Oct 2024 11:55:58 +0200 Subject: [PATCH 02/37] Add EntityWorkflowSend and EntityWorkflowSendView entities Introduced EntityWorkflowSend and EntityWorkflowSendView entities to enable tracking of workflow content sent to external parties. Updated EntityWorkflowStep to associate with these entities and added a corresponding database migration script. --- .../Entity/Workflow/EntityWorkflowSend.php | 164 ++++++++++++++++++ .../Workflow/EntityWorkflowSendView.php | 60 +++++++ .../Entity/Workflow/EntityWorkflowStep.php | 18 ++ .../migrations/Version20241003094904.php | 61 +++++++ 4 files changed, 303 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php create mode 100644 src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSendView.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20241003094904.php diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php new file mode 100644 index 000000000..ef0422fe0 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php @@ -0,0 +1,164 @@ + ''])] + private string $destineeEmail = ''; + + #[ORM\Column(type: 'uuid', unique: true, nullable: false)] + private UuidInterface $uuid; + + #[ORM\Column(type: Types::STRING, length: 255, nullable: false)] + private string $privateToken; + + #[ORM\Column(type: Types::INTEGER, nullable: false, options: ['default' => 0])] + private int $numberOfErrorTrials = 0; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: EntityWorkflowSendView::class, mappedBy: 'send')] + private Collection $views; + + public function __construct( + #[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'sends')] + #[ORM\JoinColumn(nullable: false)] + private EntityWorkflowStep $entityWorkflowStep, + string|ThirdParty $destinee, + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)] + private \DateTimeImmutable $expireAt, + ) { + $this->uuid = Uuid::uuid4(); + $random = new Randomizer(); + $this->privateToken = bin2hex($random->getBytes(48)); + + $this->entityWorkflowStep->addSend($this); + + if ($destinee instanceof ThirdParty) { + $this->destineeThirdParty = $destinee; + } else { + $this->destineeEmail = $destinee; + } + + $this->views = new ArrayCollection(); + } + + /** + * @internal use the @see{EntityWorkflowSendView}'s constructor instead + */ + public function addView(EntityWorkflowSendView $view): self + { + if (!$this->views->contains($view)) { + $this->views->add($view); + } + + return $this; + } + + public function getDestineeEmail(): string + { + return $this->destineeEmail; + } + + public function getDestineeThirdParty(): ?ThirdParty + { + return $this->destineeThirdParty; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getNumberOfErrorTrials(): int + { + return $this->numberOfErrorTrials; + } + + public function getPrivateToken(): string + { + return $this->privateToken; + } + + public function getUuid(): UuidInterface + { + return $this->uuid; + } + + public function increaseErrorTrials(): void + { + $this->numberOfErrorTrials = $this->numberOfErrorTrials + 1; + } + + public function getDestinee(): string|ThirdParty + { + if (null !== $this->getDestineeThirdParty()) { + return $this->getDestineeThirdParty(); + } + + return $this->getDestineeEmail(); + } + + /** + * Determines the kind of destinee based on whether the destinee is a thirdParty or an emailAddress. + * + * @return 'thirdParty'|'email' 'thirdParty' if the destinee is a third party, 'email' otherwise + */ + public function getDestineeKind(): string + { + if (null !== $this->getDestineeThirdParty()) { + return 'thirdParty'; + } + + return 'email'; + } + + public function isViewed(): bool + { + return $this->views->count() > 0; + } + + public function isExpired(\DateTimeImmutable $now): bool + { + return $now >= $this->expireAt; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSendView.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSendView.php new file mode 100644 index 000000000..40d9d9736 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSendView.php @@ -0,0 +1,60 @@ +send->addView($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRemoteIp(): string + { + return $this->remoteIp; + } + + public function getSend(): EntityWorkflowSend + { + return $this->send; + } + + public function getViewAt(): \DateTimeInterface + { + return $this->viewAt; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 3daacd49d..4f42dee6e 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -112,6 +112,11 @@ class EntityWorkflowStep #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)] private Collection $holdsOnStep; + /** + * @var Collection + */ + private Collection $sends; + public function __construct() { $this->ccUser = new ArrayCollection(); @@ -120,6 +125,7 @@ class EntityWorkflowStep $this->destUserByAccessKey = new ArrayCollection(); $this->signatures = new ArrayCollection(); $this->holdsOnStep = new ArrayCollection(); + $this->sends = new ArrayCollection(); $this->accessKey = bin2hex(openssl_random_pseudo_bytes(32)); } @@ -190,6 +196,18 @@ class EntityWorkflowStep return $this; } + /** + * @internal use @see{EntityWorkflowSend}'s constructor instead + */ + public function addSend(EntityWorkflowSend $send): self + { + if (!$this->sends->contains($send)) { + $this->sends[] = $send; + } + + return $this; + } + public function removeSignature(EntityWorkflowStepSignature $signature): self { if ($this->signatures->contains($signature)) { diff --git a/src/Bundle/ChillMainBundle/migrations/Version20241003094904.php b/src/Bundle/ChillMainBundle/migrations/Version20241003094904.php new file mode 100644 index 000000000..a309a927d --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20241003094904.php @@ -0,0 +1,61 @@ +addSql('CREATE SEQUENCE chill_main_workflow_entity_send_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_main_workflow_entity_send_views_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_main_workflow_entity_send (id INT NOT NULL,' + .' destineeEmail TEXT DEFAULT \'\' NOT NULL, uuid UUID NOT NULL, privateToken VARCHAR(255) NOT NULL,' + .' numberOfErrorTrials INT DEFAULT 0 NOT NULL, expireAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ' + .'createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, destineeThirdParty_id INT DEFAULT NULL, ' + .'entityWorkflowStep_id INT NOT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A0C0620FD17F50A6 ON chill_main_workflow_entity_send (uuid)'); + $this->addSql('CREATE INDEX IDX_A0C0620FDDFA98DE ON chill_main_workflow_entity_send (destineeThirdParty_id)'); + $this->addSql('CREATE INDEX IDX_A0C0620F3912FED6 ON chill_main_workflow_entity_send (entityWorkflowStep_id)'); + $this->addSql('CREATE INDEX IDX_A0C0620F3174800F ON chill_main_workflow_entity_send (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_send.uuid IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_send.expireAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_send.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_main_workflow_entity_send_views (id INT NOT NULL, send_id INT NOT NULL, ' + .'viewAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, remoteIp TEXT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_2659558513933E7B ON chill_main_workflow_entity_send_views (send_id)'); + $this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_send_views.viewAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send ADD CONSTRAINT FK_A0C0620FDDFA98DE FOREIGN KEY (destineeThirdParty_id) REFERENCES chill_3party.third_party (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send ADD CONSTRAINT FK_A0C0620F3912FED6 FOREIGN KEY (entityWorkflowStep_id) REFERENCES chill_main_workflow_entity_step (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send ADD CONSTRAINT FK_A0C0620F3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send_views ADD CONSTRAINT FK_2659558513933E7B FOREIGN KEY (send_id) REFERENCES chill_main_workflow_entity_send (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_main_workflow_entity_send_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_main_workflow_entity_send_views_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send DROP CONSTRAINT FK_A0C0620FDDFA98DE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send DROP CONSTRAINT FK_A0C0620F3912FED6'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send DROP CONSTRAINT FK_A0C0620F3174800F'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_send_views DROP CONSTRAINT FK_2659558513933E7B'); + $this->addSql('DROP TABLE chill_main_workflow_entity_send'); + $this->addSql('DROP TABLE chill_main_workflow_entity_send_views'); + } +} From a563ba644ec2a260f515184c98a4bcc8b7d0d077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 3 Oct 2024 13:56:16 +0200 Subject: [PATCH 03/37] clean the file from code in error --- .../public/page/workflow-show/index.js | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js b/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js index 1536417b1..0b19c316a 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js @@ -1,27 +1,19 @@ import {ShowHide} from 'ChillMainAssets/lib/show_hide/show_hide.js'; window.addEventListener('DOMContentLoaded', function() { - let + const divTransitions = document.querySelector('#transitions'), - futureDestUsersContainer = document.querySelector('#futureDests') - personSignatureField = document.querySelector('#person-signature-field'); - userSignatureField = document.querySelector('#user-signature-field'); - signatureTypeChoices = document.querySelector('#signature-type-choice'); - personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0'); - userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'); - signatureZone = document.querySelector('#signature-zone'); - ; - - let - transitionFilterContainer = document.querySelector('#transitionFilter'), - transitionsContainer = document.querySelector('#transitions') + futureDestUsersContainer = document.querySelector('#futureDests'), + personSignatureField = document.querySelector('#person-signature-field'), + userSignatureField = document.querySelector('#user-signature-field'), + signatureTypeChoices = document.querySelector('#signature-type-choice'), + personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0'), + userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'), + signatureZone = document.querySelector('#signature-zone'), + transitionFilterContainer = document.querySelector('#transitionFilter'), + transitionsContainer = document.querySelector('#transitions') ; - // ShowHide instance for signatureTypeChoices. This should always be present in the DOM and we toggle visibility. - // The field is not mapped and so not submitted with the form. Without it's presence upon DOM loading other show hides do not function well. - signatureTypeChoices.style.display = 'none'; - - // ShowHide instance for future dest users new ShowHide({ debug: false, @@ -34,13 +26,11 @@ window.addEventListener('DOMContentLoaded', function() { if (input.checked) { const inputData = JSON.parse(input.getAttribute('data-is-signature')) if (inputData.includes('person') || inputData.includes('user')) { - signatureTypeChoices.style.display = ''; return false; } else { personChoice.checked = false userChoice.checked = false - signatureTypeChoices.style.display = 'none'; return true; } } @@ -62,7 +52,6 @@ window.addEventListener('DOMContentLoaded', function() { if (input.checked) { const inputData = JSON.parse(input.getAttribute('data-is-signature')) if (inputData.includes('person') || inputData.includes('user')) { - signatureTypeChoices.style.display = ''; return true; } } From da6589ba87024f76a1556e4309fef62076ad8f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 3 Oct 2024 17:14:42 +0200 Subject: [PATCH 04/37] Add ThirdPartyHasEmail validator Introduce a new validator that ensures a third party has an email address, including the corresponding translation for error messaging and unit tests to verify its functionality. --- .../ThirdPartyHasEmailValidatorTest.php | 63 +++++++++++++++++++ .../Validator/ThirdPartyHasEmail.php | 31 +++++++++ .../Validator/ThirdPartyHasEmailValidator.php | 45 +++++++++++++ .../config/services.yaml | 5 ++ .../translations/validators.fr.yml | 2 + 5 files changed, 146 insertions(+) create mode 100644 src/Bundle/ChillThirdPartyBundle/Tests/Validator/ThirdPartyHasEmailValidatorTest.php create mode 100644 src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmail.php create mode 100644 src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmailValidator.php create mode 100644 src/Bundle/ChillThirdPartyBundle/translations/validators.fr.yml diff --git a/src/Bundle/ChillThirdPartyBundle/Tests/Validator/ThirdPartyHasEmailValidatorTest.php b/src/Bundle/ChillThirdPartyBundle/Tests/Validator/ThirdPartyHasEmailValidatorTest.php new file mode 100644 index 000000000..46616287c --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Tests/Validator/ThirdPartyHasEmailValidatorTest.php @@ -0,0 +1,63 @@ +setEmail('third@example.com'); + + $this->validator->validate($thirdParty, new ThirdPartyHasEmail()); + + $this->assertNoViolation(); + } + + public function testThirdPartyHasNoEmail(): void + { + $thirdParty = new ThirdParty(); + + $constraint = new ThirdPartyHasEmail(); + $constraint->message = 'message'; + + $this->validator->validate($thirdParty, $constraint); + + $this->buildViolation($constraint->message) + ->setParameter('{{ thirdParty }}', '3party-string') + ->setCode($constraint->code) + ->assertRaised(); + } + + protected function createValidator(): ThirdPartyHasEmailValidator + { + $render = $this->prophesize(ThirdPartyRender::class); + $render->renderString(Argument::type(ThirdParty::class), []) + ->willReturn('3party-string'); + + return new ThirdPartyHasEmailValidator($render->reveal()); + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmail.php b/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmail.php new file mode 100644 index 000000000..453c857c5 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmail.php @@ -0,0 +1,31 @@ +|string + */ + public function getTargets(): array|string + { + return [self::PROPERTY_CONSTRAINT]; + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmailValidator.php b/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmailValidator.php new file mode 100644 index 000000000..15f523509 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Validator/ThirdPartyHasEmailValidator.php @@ -0,0 +1,45 @@ +getEmail() || '' === $value->getEmail()) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ thirdParty }}', $this->thirdPartyRender->renderString($value, [])) + ->setCode($constraint->code) + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/config/services.yaml b/src/Bundle/ChillThirdPartyBundle/config/services.yaml index 0024556f6..572026771 100644 --- a/src/Bundle/ChillThirdPartyBundle/config/services.yaml +++ b/src/Bundle/ChillThirdPartyBundle/config/services.yaml @@ -11,3 +11,8 @@ services: autoconfigure: true resource: '../Export/' + Chill\ThirdPartyBundle\Validator\: + autoconfigure: true + autowire: true + resource: '../Validator/' + diff --git a/src/Bundle/ChillThirdPartyBundle/translations/validators.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/validators.fr.yml new file mode 100644 index 000000000..26e1562dd --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/translations/validators.fr.yml @@ -0,0 +1,2 @@ +thirdParty: + thirdParty_has_no_email: Le tiers {{ thirdParty }} n'a pas d'adresse email configurée. From 071c5e3c559c882e2958ea4a0222386fc7b04efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 3 Oct 2024 17:29:27 +0200 Subject: [PATCH 05/37] Update the form to allow sending a workflow to an external destinee OP#734 Modification du formulaire pour permettre l'envoi d'un workflow https://champs-libres.openproject.com/work_packages/734 --- .../Entity/Workflow/EntityWorkflow.php | 7 +++++ .../Entity/Workflow/EntityWorkflowStep.php | 1 + .../ChillMainBundle/Form/WorkflowStepType.php | 25 +++++++++++++++++ .../public/page/workflow-show/index.js | 27 ++++++++++++++++++- .../views/Workflow/_decision.html.twig | 7 +++++ .../Workflow/WorkflowTransitionContextDTO.php | 25 +++++++++++++++++ .../translations/messages.fr.yml | 9 +++++++ 7 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php index f68dc7b7e..d67a04b4b 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php @@ -462,6 +462,13 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface } } + foreach ($transitionContextDTO->futureDestineeThirdParties as $thirdParty) { + new EntityWorkflowSend($newStep, $thirdParty, $transitionAt->add(new \DateInterval('P30D'))); + } + foreach ($transitionContextDTO->futureDestineeEmails as $email) { + new EntityWorkflowSend($newStep, $email, $transitionAt->add(new \DateInterval('P30D'))); + } + // copy the freeze if ($this->isFreeze()) { $newStep->setFreezeAfter(true); diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 4f42dee6e..b0f4d4bf5 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -115,6 +115,7 @@ class EntityWorkflowStep /** * @var Collection */ + #[ORM\OneToMany(mappedBy: 'entityWorkflowStep', targetEntity: EntityWorkflowSend::class, cascade: ['persist'], orphanRemoval: true)] private Collection $sends; public function __construct() diff --git a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php index e55b1896d..42613ca54 100644 --- a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php +++ b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php @@ -12,14 +12,17 @@ declare(strict_types=1); namespace Chill\MainBundle\Form; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\PersonBundle\Form\Type\PickPersonDynamicType; +use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Callback; @@ -101,6 +104,7 @@ class WorkflowStepType extends AbstractType $toFinal = true; $isForward = 'neutral'; $isSignature = []; + $isSentExternal = false; $metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition); @@ -124,6 +128,8 @@ class WorkflowStepType extends AbstractType if (\array_key_exists('isSignature', $meta)) { $isSignature = $meta['isSignature']; } + + $isSentExternal = $isSentExternal ? true : $meta['isSentExternal'] ?? false; } return [ @@ -131,6 +137,7 @@ class WorkflowStepType extends AbstractType 'data-to-final' => $toFinal ? '1' : '0', 'data-is-forward' => $isForward, 'data-is-signature' => json_encode($isSignature), + 'data-is-sent-external' => $isSentExternal ? '1' : '0', ]; }, ]) @@ -166,6 +173,24 @@ class WorkflowStepType extends AbstractType 'suggested' => $options['suggested_users'], 'empty_data' => '[]', 'attr' => ['class' => 'future-cc-users'], + ]) + ->add('futureDestineeEmails', ChillCollectionType::class, [ + 'entry_type' => EmailType::class, + 'entry_options' => [ + 'empty_data' => '', + ], + 'allow_add' => true, + 'allow_delete' => true, + 'delete_empty' => static fn (?string $email) => '' === $email || null === $email, + 'button_add_label' => 'workflow.transition_destinee_add_emails', + 'button_remove_label' => 'workflow.transition_destinee_remove_emails', + 'help' => 'workflow.transition_destinee_emails_help', + 'label' => 'workflow.transition_destinee_emails_label', + ]) + ->add('futureDestineeThirdParties', PickThirdpartyDynamicType::class, [ + 'label' => 'workflow.transition_destinee_third_party', + 'help' => 'workflow.transition_destinee_third_party_help', + 'multiple' => true, ]); $builder diff --git a/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js b/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js index 0b19c316a..ce552c909 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/page/workflow-show/index.js @@ -11,7 +11,8 @@ window.addEventListener('DOMContentLoaded', function() { userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'), signatureZone = document.querySelector('#signature-zone'), transitionFilterContainer = document.querySelector('#transitionFilter'), - transitionsContainer = document.querySelector('#transitions') + transitionsContainer = document.querySelector('#transitions'), + sendExternalContainer = document.querySelector('#sendExternalContainer') ; // ShowHide instance for future dest users @@ -24,6 +25,10 @@ window.addEventListener('DOMContentLoaded', function() { for (let transition of froms) { for (let input of transition.querySelectorAll('input')) { if (input.checked) { + if ('1' === input.dataset.isSentExternal) { + return false; + } + const inputData = JSON.parse(input.getAttribute('data-is-signature')) if (inputData.includes('person') || inputData.includes('user')) { return false; @@ -40,6 +45,26 @@ window.addEventListener('DOMContentLoaded', function() { } }); + // ShowHide instance for send external + new ShowHide({ + debug: true, + load_event: null, + froms: [divTransitions], + container: [sendExternalContainer], + test: function(froms, event) { + for (let transition of froms) { + for (let input of transition.querySelectorAll('input')) { + if (input.checked) { + if ('1' === input.dataset.isSentExternal) { + return true; + } + } + } + } + return false; + } + }) + // ShowHide signature zone new ShowHide({ debug: false, diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_decision.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_decision.html.twig index abad0801a..aedee6587 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_decision.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_decision.html.twig @@ -84,6 +84,13 @@ +
+ {{ form_row(transition_form.futureDestineeThirdParties) }} + {{ form_errors(transition_form.futureDestineeThirdParties) }} + {{ form_row(transition_form.futureDestineeEmails) }} + {{ form_errors(transition_form.futureDestineeEmails) }} +
+

{{ form_label(transition_form.comment) }}

{{ form_widget(transition_form.comment) }} diff --git a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php index 370aef0cf..f00761ee4 100644 --- a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php +++ b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php @@ -15,6 +15,8 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\ThirdPartyBundle\Validator\ThirdPartyHasEmail; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Workflow\Transition; @@ -54,6 +56,29 @@ class WorkflowTransitionContextDTO */ public ?User $futureUserSignature = null; + /** + * a list of future destinee third parties, when a workflow does send the document + * to a remote third party. + * + * @var array + */ + #[Assert\All( + new ThirdPartyHasEmail(), + )] + public array $futureDestineeThirdParties = []; + + /** + * a list of future destinee emails, when a workflow does send the document to a remote + * email. + * + * @var array + */ + #[Assert\All([ + new Assert\NotBlank(), + new Assert\Email(), + ])] + public array $futureDestineeEmails = []; + public ?Transition $transition = null; public string $comment = ''; diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index bec5e1adb..3c459af16 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -558,6 +558,15 @@ workflow: Automated transition: Transition automatique waiting_for_signature: En attente de signature Permissions: Workflows (suivi de décision) + transition_destinee_third_party: Destinataire à partir des tiers externes + transition_destinee_third_party_help: Chaque destinataire recevra un lien sécurisé par courriel. + transition_destinee_emails_label: Envoi par courriel + transition_destinee_add_emails: Ajouter une adresse de courriel + transition_destinee_remove_emails: Supprimer + transition_destinee_emails_help: Le lien sécurisé sera envoyé à chaque adresse indiquée + + + signature_zone: From 7cd638c5fcde9343a3b221da2492c15af7dc9e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 4 Oct 2024 10:25:18 +0200 Subject: [PATCH 06/37] Add TransitionHasDestineeIfIsSentExternal validator This commit introduces a new validator to ensure that transitions marked as 'sent' have a designated external recipient. It includes related tests for scenarios with and without recipients and covers integration with the workflow context. --- .../ChillMainBundle/Form/WorkflowStepType.php | 1 - ...sDestineeIfIsSentExternalValidatorTest.php | 181 ++++++++++++++++++ .../TransitionHasDestineeIfIsSentExternal.php | 31 +++ ...onHasDestineeIfIsSentExternalValidator.php | 70 +++++++ .../Workflow/WorkflowTransitionContextDTO.php | 2 + .../translations/validators.fr.yml | 2 + 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestineeIfIsSentExternalValidatorTest.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestineeIfIsSentExternal.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestineeIfIsSentExternalValidator.php diff --git a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php index 42613ca54..0b63adf01 100644 --- a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php +++ b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php @@ -86,7 +86,6 @@ class WorkflowStepType extends AbstractType $builder ->add('transition', ChoiceType::class, [ 'label' => 'workflow.Next step', - 'mapped' => false, 'multiple' => false, 'expanded' => true, 'choices' => $choices, diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestineeIfIsSentExternalValidatorTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestineeIfIsSentExternalValidatorTest.php new file mode 100644 index 000000000..1f5fe4444 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestineeIfIsSentExternalValidatorTest.php @@ -0,0 +1,181 @@ +transitionToSent = new Transition('send', 'initial', 'sent'), + $this->transitionToNotSent = new Transition('notSend', 'initial', 'notSent'), + ] + ); + $builder + ->setInitialPlaces('initial') + ->setMetadataStore(new InMemoryMetadataStore( + placesMetadata: [ + 'sent' => ['isSentExternal' => true], + ] + )) + ; + + $workflow = new Workflow($builder->build(), name: 'dummy'); + $registry = new Registry(); + $registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }); + + return $registry; + } + + public function testToSentPlaceWithoutDestineeAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToSent; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::buildViolation('validation_message') + ->setCode('d78ea142-819d-11ef-a459-b7009a3e4caf') + ->atPath('property.path.futureDestineeThirdParties') + ->assertRaised(); + } + + public function testToSentPlaceWithDestineeThirdPartyDoesNotAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToSent; + $dto->futureDestineeThirdParties = [new ThirdParty()]; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testToSentPlaceWithDestineeEmailDoesNotAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToSent; + $dto->futureDestineeEmails = ['test@example.com']; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testToNoSentPlaceWithNoDestineesDoesNotAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToNotSent; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testToNoSentPlaceWithDestineeThirdPartyAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToNotSent; + $dto->futureDestineeThirdParties = [new ThirdParty()]; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::buildViolation('validation_message') + ->atPath('property.path.futureDestineeThirdParties') + ->setCode('eb8051fc-8227-11ef-8c3b-7f2de85bdc5b') + ->assertRaised(); + } + + public function testToNoSentPlaceWithDestineeEmailAddViolation(): void + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->transition = $this->transitionToNotSent; + $dto->futureDestineeEmails = ['test@example.com']; + + $constraint = new TransitionHasDestineeIfIsSentExternal(); + $constraint->messageDestineeRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::buildViolation('validation_message') + ->atPath('property.path.futureDestineeEmails') + ->setCode('eb8051fc-8227-11ef-8c3b-7f2de85bdc5b') + ->assertRaised(); + } + + protected function createValidator(): TransitionHasDestineeIfIsSentExternalValidator + { + return new TransitionHasDestineeIfIsSentExternalValidator($this->buildRegistry()); + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestineeIfIsSentExternal.php b/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestineeIfIsSentExternal.php new file mode 100644 index 000000000..6178bdce3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestineeIfIsSentExternal.php @@ -0,0 +1,31 @@ +transition) { + return; + } + + $workflow = $this->registry->get($value->entityWorkflow, $value->entityWorkflow->getWorkflowName()); + $isSentExternal = false; + foreach ($value->transition->getTos() as $to) { + $metadata = $workflow->getMetadataStore()->getPlaceMetadata($to); + + $isSentExternal = $isSentExternal ? true : $metadata['isSentExternal'] ?? false; + } + + if (!$isSentExternal) { + if (0 !== count($value->futureDestineeThirdParties)) { + $this->context->buildViolation($constraint->messageDestineeRequired) + ->atPath('futureDestineeThirdParties') + ->setCode($constraint->codeDestineeUnauthorized) + ->addViolation(); + } + if (0 !== count($value->futureDestineeEmails)) { + $this->context->buildViolation($constraint->messageDestineeRequired) + ->atPath('futureDestineeEmails') + ->setCode($constraint->codeDestineeUnauthorized) + ->addViolation(); + } + + return; + } + + if (0 === count($value->futureDestineeEmails) && 0 === count($value->futureDestineeThirdParties)) { + $this->context->buildViolation($constraint->messageDestineeRequired) + ->atPath('futureDestineeThirdParties') + ->setCode($constraint->codeNoNecessaryDestinee) + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php index f00761ee4..0a8e1ac36 100644 --- a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php +++ b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php @@ -14,6 +14,7 @@ namespace Chill\MainBundle\Workflow; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Workflow\Validator\TransitionHasDestineeIfIsSentExternal; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Validator\ThirdPartyHasEmail; @@ -24,6 +25,7 @@ use Symfony\Component\Workflow\Transition; /** * Context for a transition on an workflow entity. */ +#[TransitionHasDestineeIfIsSentExternal] class WorkflowTransitionContextDTO { /** diff --git a/src/Bundle/ChillMainBundle/translations/validators.fr.yml b/src/Bundle/ChillMainBundle/translations/validators.fr.yml index 4ab9bde34..7e852177a 100644 --- a/src/Bundle/ChillMainBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/validators.fr.yml @@ -34,6 +34,8 @@ notification: workflow: You must add at least one dest user or email: Indiquez au moins un destinataire ou une adresse email The user in cc cannot be a dest user in the same workflow step: L'utilisateur en copie ne peut pas être présent dans les utilisateurs qui valideront la prochaine étape + transition_has_destinee_if_sent_external: Indiquez un destinataire de l'envoi externe + transition_destinee_not_necessary: Pour cette transition, vous ne pouvez pas indiquer de destinataires externes rolling_date: When fixed date is selected, you must provide a date: Indiquez la date fixe choisie From 7913a377c868f4438ac00a24643e3391e06c0f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 4 Oct 2024 11:35:15 +0200 Subject: [PATCH 07/37] Move the logic to check if dest users are required to a dedicated constraint - Create a dedicated constraint to check if the destUsers are required by the applied transition. - Apply on WorkflowTransitionContextDTO and, if required, use the built-in constraints - create tests --- .../ChillMainBundle/Form/WorkflowStepType.php | 36 +-- ...tionHasDestUserIfRequiredValidatorTest.php | 209 ++++++++++++++++++ .../TransitionHasDestUserIfRequired.php | 31 +++ ...ansitionHasDestUserIfRequiredValidator.php | 79 +++++++ .../Workflow/WorkflowTransitionContextDTO.php | 3 + 5 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestUserIfRequiredValidatorTest.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestUserIfRequired.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestUserIfRequiredValidator.php diff --git a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php index 0b63adf01..b786a450d 100644 --- a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php +++ b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php @@ -25,9 +25,7 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Constraints\NotNull; -use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Transition; @@ -206,38 +204,6 @@ class WorkflowStepType extends AbstractType ->setDefault('data_class', WorkflowTransitionContextDTO::class) ->setRequired('entity_workflow') ->setAllowedTypes('entity_workflow', EntityWorkflow::class) - ->setDefault('suggested_users', []) - ->setDefault('constraints', [ - new Callback( - function (WorkflowTransitionContextDTO $step, ExecutionContextInterface $context, $payload) { - $workflow = $this->registry->get($step->entityWorkflow, $step->entityWorkflow->getWorkflowName()); - $transition = $step->transition; - $toFinal = true; - - if (null === $transition) { - $context - ->buildViolation('workflow.You must select a next step, pick another decision if no next steps are available'); - } else { - foreach ($transition->getTos() as $to) { - $meta = $workflow->getMetadataStore()->getPlaceMetadata($to); - - if ( - !\array_key_exists('isFinal', $meta) || false === $meta['isFinal'] - ) { - $toFinal = false; - } - } - $destUsers = $step->futureDestUsers; - - if (!$toFinal && [] === $destUsers) { - $context - ->buildViolation('workflow.You must add at least one dest user or email') - ->atPath('future_dest_users') - ->addViolation(); - } - } - } - ), - ]); + ->setDefault('suggested_users', []); } } diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestUserIfRequiredValidatorTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestUserIfRequiredValidatorTest.php new file mode 100644 index 000000000..4d7f027d1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Validator/TransitionHasDestUserIfRequiredValidatorTest.php @@ -0,0 +1,209 @@ +transitionToSent = new Transition('send', 'initial', 'sent'); + $this->transitionSignature = new Transition('signature', 'initial', 'signature'); + $this->transitionRegular = new Transition('regular', 'initial', 'regular'); + $this->transitionFinal = new Transition('final', 'initial', 'final'); + + parent::setUp(); + } + + public function testTransitionToRegularWithDestUsersRaiseNoViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionRegular; + $dto->futureDestUsers = [new User()]; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testTransitionToRegularWithNoUsersRaiseViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionRegular; + + $constraint = new TransitionHasDestUserIfRequired(); + $constraint->messageDestUserRequired = 'validation_message'; + + $this->validator->validate($dto, $constraint); + + self::buildViolation($constraint->messageDestUserRequired) + ->setCode($constraint->codeDestUserRequired) + ->atPath('property.path.futureDestUsers') + ->assertRaised(); + } + + public function testTransitionToSignatureWithUserRaiseViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionSignature; + $dto->futureDestUsers = [new User()]; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::buildViolation($constraint->messageDestUserNotAuthorized) + ->setCode($constraint->codeDestUserNotAuthorized) + ->atPath('property.path.futureDestUsers') + ->assertRaised(); + } + + public function testTransitionToExternalSendWithUserRaiseViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionToSent; + $dto->futureDestUsers = [new User()]; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::buildViolation($constraint->messageDestUserNotAuthorized) + ->setCode($constraint->codeDestUserNotAuthorized) + ->atPath('property.path.futureDestUsers') + ->assertRaised(); + } + + public function testTransitionToFinalWithUserRaiseViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionFinal; + $dto->futureDestUsers = [new User()]; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::buildViolation($constraint->messageDestUserNotAuthorized) + ->setCode($constraint->codeDestUserNotAuthorized) + ->atPath('property.path.futureDestUsers') + ->assertRaised(); + } + + public function testTransitionToSignatureWithNoUserNoViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionSignature; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testTransitionToExternalSendWithNoUserNoViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionToSent; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + public function testTransitionToFinalWithNoUserNoViolation(): void + { + $dto = $this->buildDto(); + $dto->transition = $this->transitionFinal; + + $constraint = new TransitionHasDestUserIfRequired(); + + $this->validator->validate($dto, $constraint); + + self::assertNoViolation(); + } + + private function buildDto(): WorkflowTransitionContextDTO + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + return new WorkflowTransitionContextDTO($entityWorkflow); + } + + private function buildRegistry(): Registry + { + $builder = new DefinitionBuilder( + ['initial', 'sent', 'signature', 'regular', 'final'], + [ + $this->transitionToSent, + $this->transitionSignature, + $this->transitionRegular, + $this->transitionFinal, + ] + ); + $builder + ->setInitialPlaces('initial') + ->setMetadataStore(new InMemoryMetadataStore( + placesMetadata: [ + 'sent' => ['isSentExternal' => true], + 'signature' => ['isSignature' => ['person', 'user']], + 'final' => ['isFinal' => true], + ] + )) + ; + + $workflow = new Workflow($builder->build(), name: 'dummy'); + $registry = new Registry(); + $registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }); + + return $registry; + } + + protected function createValidator(): TransitionHasDestUserIfRequiredValidator + { + return new TransitionHasDestUserIfRequiredValidator($this->buildRegistry()); + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestUserIfRequired.php b/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestUserIfRequired.php new file mode 100644 index 000000000..5bda39ea6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/Validator/TransitionHasDestUserIfRequired.php @@ -0,0 +1,31 @@ +transition) { + return; + } + + $workflow = $this->registry->get($value->entityWorkflow, $value->entityWorkflow->getWorkflowName()); + $metadataStore = $workflow->getMetadataStore(); + + $destUsersRequired = false; + + foreach ($value->transition->getTos() as $to) { + $metadata = $metadataStore->getPlaceMetadata($to); + + // if the place are only 'isSentExternal' or 'isSignature' or 'final', then, we skip - a destUser is not required + if ($metadata['isSentExternal'] ?? false) { + continue; + } + if ($metadata['isSignature'] ?? false) { + continue; + } + if ($metadata['isFinal'] ?? false) { + continue; + } + // if there isn't any 'isSentExternal' or 'isSignature' or final, then we must have a destUser + $destUsersRequired = true; + } + + if (!$destUsersRequired) { + if (0 < count($value->futureDestUsers)) { + $this->context->buildViolation($constraint->messageDestUserNotAuthorized) + ->setCode($constraint->codeDestUserNotAuthorized) + ->atPath('futureDestUsers') + ->addViolation(); + } + + return; + } + + if (0 === count($value->futureDestUsers)) { + $this->context->buildViolation($constraint->messageDestUserRequired) + ->setCode($constraint->codeDestUserRequired) + ->atPath('futureDestUsers') + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php index 0a8e1ac36..324bef7e8 100644 --- a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php +++ b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Workflow\Validator\TransitionHasDestineeIfIsSentExternal; +use Chill\MainBundle\Workflow\Validator\TransitionHasDestUserIfRequired; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Validator\ThirdPartyHasEmail; @@ -26,6 +27,7 @@ use Symfony\Component\Workflow\Transition; * Context for a transition on an workflow entity. */ #[TransitionHasDestineeIfIsSentExternal] +#[TransitionHasDestUserIfRequired] class WorkflowTransitionContextDTO { /** @@ -81,6 +83,7 @@ class WorkflowTransitionContextDTO ])] public array $futureDestineeEmails = []; + #[Assert\NotNull] public ?Transition $transition = null; public string $comment = ''; From a0b5c208eb05bb81b8385664e61dbb19e2c977c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 4 Oct 2024 13:40:50 +0200 Subject: [PATCH 08/37] Send an email when a workflow is send to an external - create an event subscriber to catch the workflow which arrive to a "sentExternal" step; - add a messenger's message to handle the generation of the email; - add a simple message, and a simple controller for viewing the document - add dedicated tests --- .../WorkflowViewSendPublicController.php | 25 ++++ .../Entity/Workflow/EntityWorkflowSend.php | 10 ++ .../Entity/Workflow/EntityWorkflowStep.php | 8 + ..._send_external_email_to_destinee.html.twig | 6 + ...EmailOnSendExternalEventSubscriberTest.php | 140 ++++++++++++++++++ .../PostSendExternalMessageHandlerTest.php | 77 ++++++++++ ...pareEmailOnSendExternalEventSubscriber.php | 68 +++++++++ .../Messenger/PostSendExternalMessage.php | 20 +++ .../PostSendExternalMessageHandler.php | 57 +++++++ .../ChillMainBundle/config/services.yaml | 8 +- .../translations/messages+intl-icu.fr.yaml | 2 + 11 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php create mode 100644 src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_external_email_to_destinee.html.twig create mode 100644 src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriberTest.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriber.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Messenger/PostSendExternalMessage.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Messenger/PostSendExternalMessageHandler.php diff --git a/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php new file mode 100644 index 000000000..88269d5bb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php @@ -0,0 +1,25 @@ +uuid; } + public function getExpireAt(): \DateTimeImmutable + { + return $this->expireAt; + } + + public function getViews(): Collection + { + return $this->views; + } + public function increaseErrorTrials(): void { $this->numberOfErrorTrials = $this->numberOfErrorTrials + 1; diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index b0f4d4bf5..cd60d78d1 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -303,6 +303,14 @@ class EntityWorkflowStep return $this->signatures; } + /** + * @return Collection + */ + public function getSends(): Collection + { + return $this->sends; + } + public function getId(): ?int { return $this->id; diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_external_email_to_destinee.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_external_email_to_destinee.html.twig new file mode 100644 index 000000000..3dc31f1a6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_external_email_to_destinee.html.twig @@ -0,0 +1,6 @@ + +Un message vous a été envoyé. Vous pouvez le consulter à cette adresse + +{{ absolute_url(path('chill_main_workflow_send_view_public', {'uuid': send.uuid, 'verificationKey': send.privateToken})) }} + +{{ 'workflow.send_external_message.document_available_until'|trans({ 'expiration': send.expireAt}, null, lang) }} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriberTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriberTest.php new file mode 100644 index 000000000..a72990af6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriberTest.php @@ -0,0 +1,140 @@ +prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::type(PostSendExternalMessage::class)) + ->will(fn ($args) => new Envelope($args[0])) + ->shouldBeCalled(); + + $registry = $this->buildRegistry($messageBus->reveal()); + + $entityWorkflow = $this->buildEntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + + $workflow = $registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $workflow->apply( + $entityWorkflow, + $this->transitionSendExternal->getName(), + ['context' => $dto, 'byUser' => new User(), 'transition' => $this->transitionSendExternal->getName(), + 'transitionAt' => new \DateTimeImmutable()] + ); + + // at this step, prophecy should check that the dispatch method has been called + } + + public function testToRegularDoNotGenerateMessage(): void + { + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::type(PostSendExternalMessage::class)) + ->shouldNotBeCalled(); + + $registry = $this->buildRegistry($messageBus->reveal()); + + $entityWorkflow = $this->buildEntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + + $workflow = $registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $workflow->apply( + $entityWorkflow, + $this->transitionRegular->getName(), + ['context' => $dto, 'byUser' => new User(), 'transition' => $this->transitionRegular->getName(), + 'transitionAt' => new \DateTimeImmutable()] + ); + + // at this step, prophecy should check that the dispatch method has been called + } + + private function buildEntityWorkflow(): EntityWorkflow + { + $entityWorkflow = new EntityWorkflow(); + $entityWorkflow->setWorkflowName('dummy'); + + // set an id + $reflectionClass = new \ReflectionClass($entityWorkflow); + $idProperty = $reflectionClass->getProperty('id'); + $idProperty->setValue($entityWorkflow, 1); + + return $entityWorkflow; + } + + private function buildRegistry(MessageBusInterface $messageBus): Registry + { + $builder = new DefinitionBuilder( + ['initial', 'sendExternal', 'regular'], + [ + $this->transitionSendExternal = new Transition('toSendExternal', 'initial', 'sendExternal'), + $this->transitionRegular = new Transition('toRegular', 'initial', 'regular'), + ] + ); + + $builder + ->setInitialPlaces('initial') + ->setMetadataStore(new InMemoryMetadataStore( + placesMetadata: [ + 'sendExternal' => ['isSentExternal' => true], + ] + )); + + $entityMarkingStore = new EntityWorkflowMarkingStore(); + $registry = new Registry(); + + $eventSubscriber = new EntityWorkflowPrepareEmailOnSendExternalEventSubscriber($registry, $messageBus); + $eventSubscriber->setLocale('fr'); + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($eventSubscriber); + + $workflow = new Workflow($builder->build(), $entityMarkingStore, $eventDispatcher, 'dummy'); + $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/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php new file mode 100644 index 000000000..a6f3172a7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php @@ -0,0 +1,77 @@ +buildEntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestineeEmails = ['external@example.com']; + $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; + $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); + + $repository = $this->prophesize(EntityWorkflowRepository::class); + $repository->find(1)->willReturn($entityWorkflow); + + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); + $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); + + $bodyRenderer = $this->prophesize(BodyRendererInterface::class); + $bodyRenderer->render(Argument::type(TemplatedEmail::class))->shouldBeCalledTimes(2); + + $handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $bodyRenderer->reveal()); + + $handler(new PostSendExternalMessage(1, 'fr')); + + // prophecy should do the check at the end of this test + } + + private function buildCheckAddressCallback(string $emailToCheck): callable + { + return fn(TemplatedEmail $email): bool => in_array($emailToCheck, array_map(fn (Address $addr) => $addr->getAddress(), $email->getTo()), true); + } + + private function buildEntityWorkflow(): EntityWorkflow + { + $entityWorkflow = new EntityWorkflow(); + $reflection = new \ReflectionClass($entityWorkflow); + $idProperty = $reflection->getProperty('id'); + $idProperty->setValue($entityWorkflow, 1); + + return $entityWorkflow; + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriber.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriber.php new file mode 100644 index 000000000..9eeaaeaa5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowPrepareEmailOnSendExternalEventSubscriber.php @@ -0,0 +1,68 @@ + 'onWorkflowCompleted', + ]; + } + + public function onWorkflowCompleted(CompletedEvent $event): void + { + $entityWorkflow = $event->getSubject(); + + if (!$entityWorkflow instanceof EntityWorkflow) { + return; + } + + $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $store = $workflow->getMetadataStore(); + + $mustSend = false; + foreach ($event->getTransition()->getTos() as $to) { + $metadata = $store->getPlaceMetadata($to); + if ($metadata['isSentExternal'] ?? false) { + $mustSend = true; + } + } + + if ($mustSend) { + $this->messageBus->dispatch(new PostSendExternalMessage($entityWorkflow->getId(), $this->getLocale())); + } + } + + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/Messenger/PostSendExternalMessage.php b/src/Bundle/ChillMainBundle/Workflow/Messenger/PostSendExternalMessage.php new file mode 100644 index 000000000..40ee49bbc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/Messenger/PostSendExternalMessage.php @@ -0,0 +1,20 @@ +entityWorkflowRepository->find($message->entityWorkflowId); + + if (null === $entityWorkflow) { + throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId)); + } + + foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) { + $this->sendEmailToDestinee($send, $message); + } + } + + private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void + { + $email = new TemplatedEmail(); + $email + ->to($send->getDestineeThirdParty()?->getEmail() ?? $send->getDestineeEmail()) + ->htmlTemplate('@ChillMain/Workflow/workflow_send_external_email_to_destinee.html.twig') + ->context([ + 'send' => $send, + 'lang' => $message->lang, + ]); + + $this->bodyRenderer->render($email); + $this->mailer->send($email); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index 8b4aaa7bf..86f15097b 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -33,15 +33,15 @@ services: # workflow related Chill\MainBundle\Workflow\: resource: '../Workflow/' - autowire: true - autoconfigure: true Chill\MainBundle\Workflow\EntityWorkflowManager: - autoconfigure: true - autowire: true arguments: $handlers: !tagged_iterator chill_main.workflow_handler + # seems to have no alias on symfony 5.4 + Symfony\Component\Mime\BodyRendererInterface: + alias: 'twig.mime_body_renderer' + # other stuffes chill.main.helper.translatable_string: diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 9100f8ab8..59374ca57 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -67,6 +67,8 @@ workflow: one {Signature demandée} other {Signatures demandées} } + send_external_message: + document_available_until: Le lien sera valable jusqu'au {expiration, date, long} à {expiration, time, short}. duration: minute: >- From 5c0f3cb317307e38591f28b6ce31955969ed1a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 7 Oct 2024 15:35:36 +0200 Subject: [PATCH 09/37] Implement the controller action to view the EntityworkflowSend --- .../WorkflowViewSendPublicController.php | 63 ++++- .../Entity/Workflow/EntityWorkflowSend.php | 5 + ...orkflow_view_send_public_expired.html.twig | 12 + .../WorkflowViewSendPublicControllerTest.php | 234 ++++++++++++++++++ .../PostSendExternalMessageHandlerTest.php | 2 +- .../Workflow/EntityWorkflowManager.php | 29 +++ .../EntityWorkflowWithPublicViewInterface.php | 24 ++ ...HandlerWithPublicViewNotFoundException.php | 14 ++ .../translations/messages.fr.yml | 3 + 9 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig create mode 100644 src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/Exception/HandlerWithPublicViewNotFoundException.php diff --git a/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php index 88269d5bb..084fcdce0 100644 --- a/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php +++ b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php @@ -12,14 +12,69 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; +use Chill\MainBundle\Entity\Workflow\EntityWorkflowSendView; +use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Chill\MainBundle\Workflow\Exception\HandlerWithPublicViewNotFoundException; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Twig\Environment; -class WorkflowViewSendPublicController +final readonly class WorkflowViewSendPublicController { - #[Route('/public/main/workflow/send/{uuid}/view/{verificationKey}', methods: ['GET'], name: 'chill_main_workflow_send_view_public')] - public function __invoke(EntityWorkflowSend $workflowSend, string $verificationKey): Response + public const LOG_PREFIX = '[workflow-view-send-public-controller] '; + + public function __construct( + private EntityManagerInterface $entityManager, + private LoggerInterface $chillLogger, + private EntityWorkflowManager $entityWorkflowManager, + private ClockInterface $clock, + private Environment $environment, + ) {} + + #[Route('/public/main/workflow/send/{uuid}/view/{verificationKey}', name: 'chill_main_workflow_send_view_public', methods: ['GET'])] + public function __invoke(EntityWorkflowSend $workflowSend, string $verificationKey, Request $request): Response { - return new Response('ok'); + if (50 < $workflowSend->getNumberOfErrorTrials()) { + throw new AccessDeniedHttpException('number of trials exceeded, no more access allowed'); + } + + if ($verificationKey !== $workflowSend->getPrivateToken()) { + $this->chillLogger->info(self::LOG_PREFIX.'Invalid trial for this send', ['client_ip' => $request->getClientIp()]); + $workflowSend->increaseErrorTrials(); + $this->entityManager->flush(); + + throw new AccessDeniedHttpException('invalid verification key'); + } + + if ($this->clock->now() > $workflowSend->getExpireAt()) { + return new Response( + $this->environment->render('@ChillMain/Workflow/workflow_view_send_public_expired.html.twig'), + 409 + ); + } + + if (100 < $workflowSend->getViews()->count()) { + $this->chillLogger->info(self::LOG_PREFIX.'100 view reached, not allowed to see it again'); + throw new AccessDeniedHttpException('100 views reached, not allowed to see it again'); + } + + try { + $response = new Response( + $this->entityWorkflowManager->renderPublicView($workflowSend), + ); + + $view = new EntityWorkflowSendView($workflowSend, $this->clock->now(), $request->getClientIp()); + $this->entityManager->persist($view); + $this->entityManager->flush(); + + return $response; + } catch (HandlerWithPublicViewNotFoundException $e) { + throw new \RuntimeException('Could not render the public view', previous: $e); + } } } diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php index dd6aa9044..186cf64e6 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php @@ -119,6 +119,11 @@ class EntityWorkflowSend implements TrackCreationInterface return $this->privateToken; } + public function getEntityWorkflowStep(): EntityWorkflowStep + { + return $this->entityWorkflowStep; + } + public function getUuid(): UuidInterface { return $this->uuid; diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig new file mode 100644 index 000000000..d026c37b1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig @@ -0,0 +1,12 @@ + + + + + {{ 'workflow.public_link.expired_link_title'|trans }} + + +

{{ 'workflow.public_link.expired_link_title'|trans }}

+ +

{{ 'workflow.public_link.expired_link_explanation'|trans }}

+ + diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php new file mode 100644 index 000000000..d9728b986 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php @@ -0,0 +1,234 @@ +prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock(), $environment->reveal()); + + self::expectException(AccessDeniedHttpException::class); + + $send = $this->buildEntityWorkflowSend(); + + for ($i = 0; $i < 51; ++$i) { + $send->increaseErrorTrials(); + } + + $controller($send, $send->getPrivateToken(), new Request()); + } + + public function testInvalidVerificationKey(): void + { + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock(), $environment->reveal()); + + self::expectException(AccessDeniedHttpException::class); + + $send = $this->buildEntityWorkflowSend(); + + try { + $controller($send, 'some-token', new Request()); + } catch (AccessDeniedHttpException $e) { + self::assertEquals(1, $send->getNumberOfErrorTrials()); + + throw $e; + } + } + + public function testExpiredLink(): void + { + $environment = $this->prophesize(Environment::class); + $environment->render('@ChillMain/Workflow/workflow_view_send_public_expired.html.twig')->willReturn('test'); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock('next year'), $environment->reveal()); + + $send = $this->buildEntityWorkflowSend(); + + $response = $controller($send, $send->getPrivateToken(), new Request()); + + self::assertEquals('test', $response->getContent()); + self::assertEquals(409, $response->getStatusCode()); + } + + public function testNoHandlerFound(): void + { + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController( + $entityManager->reveal(), + new NullLogger(), + new EntityWorkflowManager([], new Registry()), + new MockClock(), + $environment->reveal(), + ); + + self::expectException(\RuntimeException::class); + + $send = $this->buildEntityWorkflowSend(); + $controller($send, $send->getPrivateToken(), new Request()); + } + + public function testHappyScenario(): void + { + $send = $this->buildEntityWorkflowSend(); + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function (EntityWorkflowSendView $view) use ($send) { + return $send === $view->getSend(); + }))->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + + $controller = new WorkflowViewSendPublicController( + $entityManager->reveal(), + new NullLogger(), + new EntityWorkflowManager([ + $this->buildFakeHandler(), + ], new Registry()), + new MockClock(), + $environment->reveal(), + ); + + $response = $controller($send, $send->getPrivateToken(), $this->buildRequest()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('content', $response->getContent()); + } + + private function buildFakeHandler(): EntityWorkflowHandlerInterface&EntityWorkflowWithPublicViewInterface + { + return new class () implements EntityWorkflowWithPublicViewInterface, EntityWorkflowHandlerInterface { + public function getDeletionRoles(): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getEntityData(EntityWorkflow $entityWorkflow, array $options = []): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getEntityTitle(EntityWorkflow $entityWorkflow, array $options = []): string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRelatedEntity(EntityWorkflow $entityWorkflow): ?object + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRelatedObjects(object $object): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRoleShow(EntityWorkflow $entityWorkflow): ?string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getSuggestedUsers(EntityWorkflow $entityWorkflow): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getTemplateData(EntityWorkflow $entityWorkflow, array $options = []): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function isObjectSupported(object $object): bool + { + throw new \BadMethodCallException('not implemented'); + } + + public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool + { + return true; + } + + public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool + { + return false; + } + + public function findByRelatedEntity(object $object): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function renderPublicView(EntityWorkflowSend $entityWorkflowSend): string + { + return 'content'; + } + }; + } + + private function buildRequest(): Request + { + return Request::create('/test', server: ['REMOTE_ADDR' => '10.0.0.10']); + } + + private function buildEntityWorkflowSend(): EntityWorkflowSend + { + $entityWorkflow = new EntityWorkflow(); + + $step = $entityWorkflow->getCurrentStep(); + + return new EntityWorkflowSend($step, new ThirdParty(), new \DateTimeImmutable('next month')); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php index a6f3172a7..9f3542291 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php @@ -62,7 +62,7 @@ class PostSendExternalMessageHandlerTest extends TestCase private function buildCheckAddressCallback(string $emailToCheck): callable { - return fn(TemplatedEmail $email): bool => in_array($emailToCheck, array_map(fn (Address $addr) => $addr->getAddress(), $email->getTo()), true); + return fn (TemplatedEmail $email): bool => in_array($emailToCheck, array_map(fn (Address $addr) => $addr->getAddress(), $email->getTo()), true); } private function buildEntityWorkflow(): EntityWorkflow diff --git a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php index 9f7b54ccd..f054826c4 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php +++ b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php @@ -13,9 +13,16 @@ namespace Chill\MainBundle\Workflow; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; use Chill\MainBundle\Workflow\Exception\HandlerNotFoundException; +use Chill\MainBundle\Workflow\Exception\HandlerWithPublicViewNotFoundException; use Symfony\Component\Workflow\Registry; +/** + * Manage the handler and performs some operation on handlers. + * + * Each handler must implement @{EntityWorkflowHandlerInterface::class}. + */ class EntityWorkflowManager { /** @@ -63,4 +70,26 @@ class EntityWorkflowManager return []; } + + /** + * Renders the public view for the given entity workflow send. + * + * @param EntityWorkflowSend $entityWorkflowSend the entity workflow send object + * + * @return string the rendered public view + * + * @throws HandlerWithPublicViewNotFoundException if no handler with public view is found + */ + public function renderPublicView(EntityWorkflowSend $entityWorkflowSend): string + { + $entityWorkflow = $entityWorkflowSend->getEntityWorkflowStep()->getEntityWorkflow(); + + foreach ($this->handlers as $handler) { + if ($handler instanceof EntityWorkflowWithPublicViewInterface && $handler->supports($entityWorkflow)) { + return $handler->renderPublicView($entityWorkflowSend); + } + } + + throw new HandlerWithPublicViewNotFoundException(); + } } diff --git a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php new file mode 100644 index 000000000..2d4eefb9e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php @@ -0,0 +1,24 @@ + Date: Tue, 8 Oct 2024 15:15:15 +0200 Subject: [PATCH 10/37] Add direct download link feature with new button implementation Introduce a new feature that allows for direct download links by integrating TempUrlGeneratorInterface. Added new DOWNLOAD_LINK_ONLY group and corresponding logic to generate download links in StoredObjectNormalizer. Implement a new Twig filter and Vue component for rendering the download button. Updated tests to cover the new functionality. --- .../public/module/button_download/index.ts | 27 ++++++++++ .../Resources/public/types.ts | 2 + .../StoredObjectButton/DownloadButton.vue | 41 +++++++++++---- .../vuejs/StoredObjectButton/helpers.ts | 9 +++- .../views/Button/button_download.html.twig | 1 + .../Normalizer/StoredObjectNormalizer.php | 26 ++++++++++ .../Templating/WopiEditTwigExtension.php | 4 ++ .../WopiEditTwigExtensionRuntime.php | 12 +++++ .../Normalizer/StoredObjectNormalizerTest.php | 50 ++++++++++++++++++- .../chill.webpack.config.js | 3 +- 10 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_download.html.twig diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts new file mode 100644 index 000000000..9ffe696c7 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts @@ -0,0 +1,27 @@ +import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; +import {createApp} from "vue"; +import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue"; +import {StoredObject, StoredObjectStatusChange} from "../../types"; +import {defineComponent} from "vue"; +import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue"; +import ToastPlugin from "vue-toast-notification"; + + + +const i18n = _createI18n({}); + +window.addEventListener('DOMContentLoaded', function (e) { + document.querySelectorAll('div[data-download-button-single]').forEach((el) => { + const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject; + const title = el.dataset.title as string; + const app = createApp({ + components: {DownloadButton}, + data() { + return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}}; + }, + template: '', + }); + + app.use(i18n).use(ToastPlugin).mount(el); + }); +}); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts index f93e9c5b7..2a998fb63 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -2,6 +2,7 @@ import { DateTime, User, } from "../../../ChillMainBundle/Resources/public/types"; +import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers"; export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending"; @@ -30,6 +31,7 @@ export interface StoredObject { href: string; expiration: number; }; + downloadLink?: SignedUrlGet; }; } diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue index d1efabcee..ecf9055af 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue @@ -1,5 +1,5 @@