Browse Source

Merge remote-tracking branch 'origin/feature/mercure' into develop

Conflicts:
	.env
	.env.preprod
	composer.lock
	config/packages/dev/messenger.yaml
	src/Entity/Core/File.php
	src/Entity/Core/Notification.php
Olivier Massot 3 years ago
parent
commit
68eab02cc1

+ 11 - 1
.env

@@ -84,4 +84,14 @@ MAILER_DSN=smtp://localhost
 ###> elasticsearch ###
 ELASTICSEARCH_HOST=localhost
 ELASTICSEARCH_PORT=9200
-###< elasticsearch ###
+###< elasticsearch ###
+
+###> symfony/mercure-bundle ###
+# See https://symfony.com/doc/current/mercure.html#configuration
+# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
+MERCURE_URL=http://mercure/.well-known/mercure
+# The public URL of the Mercure hub, used by the browser to connect
+MERCURE_PUBLIC_URL=https://local.mercure.opentalent.fr/.well-known/mercure
+# The secret key used to sign the JWTs
+MERCURE_PUBLISHER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
+###< symfony/mercure-bundle ###

+ 10 - 0
.env.preprod

@@ -30,3 +30,13 @@ DATABASE_ADMINASSOS_URL=mysql://root:mysql2iopenservice369566@preprod:3306/admin
 ###> dolibarr client ###
 DOLIBARR_API_BASE_URI=https://dev-erp.2iopenservice.com/api/index.php/
 ###< dolibarr client ###
+
+###> symfony/mercure-bundle ###
+# See https://symfony.com/doc/current/mercure.html#configuration
+# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
+MERCURE_URL=https://preprod.mercure.opentalent.fr/.well-known/mercure
+# The public URL of the Mercure hub, used by the browser to connect
+MERCURE_PUBLIC_URL=https://preprod.mercure.opentalent.fr/.well-known/mercure
+# The secret used to sign the JWTs
+MERCURE_JWT_SECRET=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
+###< symfony/mercure-bundle ###

+ 1 - 1
.gitlab-ci.yml

@@ -34,7 +34,7 @@ unit:
 
   script:
     - php composer.phar --no-interaction --quiet install
-    - ./bin/phpunit --configuration ./phpunit.xml.dist --coverage-text --colors=never
+    - php bin/phpunit --configuration phpunit.xml.dist --colors=never --no-interaction
 
   artifacts:
     paths:

+ 3 - 0
composer.json

@@ -22,6 +22,7 @@
         "jbouzekri/phumbor-bundle": "^2.1",
         "knplabs/knp-gaufrette-bundle": "^0.7.1",
         "knplabs/knp-snappy-bundle": "^1.9",
+        "lcobucci/jwt": "^4.1",
         "lexik/jwt-authentication-bundle": "^2.8",
         "myclabs/php-enum": "^1.7",
         "nelmio/cors-bundle": "^2.1",
@@ -37,6 +38,8 @@
         "symfony/framework-bundle": "5.4.*",
         "symfony/http-client": "5.4.*",
         "symfony/intl": "5.4.*",
+        "symfony/mercure": "^0.6.1",
+        "symfony/mercure-bundle": "^0.3.4",
         "symfony/monolog-bundle": "^3.7",
         "symfony/property-access": "5.4.*",
         "symfony/property-info": "5.4.*",

+ 1 - 0
config/bundles.php

@@ -18,4 +18,5 @@ return [
     Knp\Bundle\GaufretteBundle\KnpGaufretteBundle::class => ['all' => true],
     Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
     Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
+    Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
 ];

+ 5 - 5
config/packages/dev/messenger.yaml

@@ -1,6 +1,6 @@
 framework:
-    messenger:
-        transports:
-            # https://symfony.com/doc/current/messenger.html#transport-configuration
-            async: 'sync://'
-            failed: 'sync://'
+  messenger:
+    transports:
+      # https://symfony.com/doc/current/messenger.html#transport-configuration
+      async: 'sync://'
+      failed: 'sync://'

+ 11 - 0
config/packages/mercure.yaml

@@ -0,0 +1,11 @@
+mercure:
+    hubs:
+        default:
+            url: '%env(MERCURE_URL)%'
+            public_url: '%env(MERCURE_PUBLIC_URL)%'
+            jwt:
+                secret: '%env(MERCURE_PUBLISHER_JWT_KEY)%'
+                algorithm: 'hmac.sha256'
+                publish: ['*']
+
+#            jwt_provider: App\Mercure\JWTProvider

+ 67 - 0
doc/mercure.md

@@ -0,0 +1,67 @@
+# Mercure
+
+On utilise mercure pour envoyer des updates en temps réel depuis le back vers les postes clients.
+
+Voir :
+
+* <https://mercure.rocks/docs>
+* <https://symfony.com/doc/5.4/mercure.html>
+
+## Fonctionnement général
+
+Un hub mercure écoute à une url donnée (en local : <https://local.mercure.opentalent.fr>).
+
+Ap2i utilise le bundle mercure pour symfony pour publier des updates à destination d'un ou plusieurs utilisateurs :
+
+Exemple : 
+
+    $update = new Update(
+        "access/{$access->getId()}",
+        json_encode(['myData' => 'some new data'], JSON_THROW_ON_ERROR),
+        true
+    );
+    $this->mercureHub->publish($update);
+
+Le client web, lui s'est abonné aux mises à jour
+
+    const url = new URL($config.baseUrl_mercure)
+    url.searchParams.append('topic', "access/" + store.state.profile.access.id)
+
+    eventSource.value = new EventSourcePolyfill(url.toString(), { withCredentials: true });
+    
+    eventSource.value.onerror = event => {
+        console.error('Error while subscribing to the EventSource : ' + JSON.stringify(event))
+    }
+    eventSource.value.onopen = event => {
+        console.log('Listening for events...')
+    }
+    eventSource.value.onmessage = event => {
+        const data = JSON.parse(event.data)
+        console.log('we received an update with data : ' + JSON.stringify(data)) 
+    }
+
+
+## Sécurité
+
+Pour sécuriser les échanges et s'assurer que seul l'utilisateur voulu recoive l'update, on utilise le [système d'autorisations
+prévu par mercure](https://symfony.com/doc/5.4/mercure.html#authorization).
+
+Les updates publiées par ap2i sont marquées comme `private`, via le 3e argument passé au constructeur de l'update :
+
+    $update = new Update($topics, $data, true);
+
+À partir de là, seuls les abonnés en mesure de fournir un JWT valide pourront recevoir les updates.
+
+On construit ce JWT côté back au moment du login, et on le stocke dans un cookie `mercureAuthorization` renvoyé au
+client. Le contenu de ce JWT spécifie que le client ne peut s'abonner qu'aux sujets suivants :
+
+* `access/{id}`
+
+Ce JWT est crypté avec l'algo HS256 au moyen d'une clé secrète.
+
+> Pour tester la construction des JWT : <https://jwt.io/>
+
+On ajoute aussi la configuration `{ withCredentials: true }` au EventSource côté client, pour lui indiquer de transmettre
+les cookies au hub Mercure.
+
+Enfin, le hub mercure est configuré de manière à interdire les updates anonymes.

+ 8 - 0
readme.md

@@ -2,3 +2,11 @@
 
 [![pipeline status](http://gitlab.2iopenservice.com/opentalent/api/badges/master/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/api/-/commits/master)
 [![coverage report](http://gitlab.2iopenservice.com/opentalent/api/badges/master/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/api/-/commits/master)
+
+## Démarrer le handler de messenger
+
+Pour consommer les signaux messengers, lancer :
+
+    php bin/console messenger:consume async
+
+> Voir: <https://symfony.com/doc/5.4/messenger.html#consuming-messages-running-the-worker>

+ 22 - 0
src/ApiResources/Export/ExportRequest.php

@@ -42,6 +42,12 @@ abstract class ExportRequest
      */
     protected bool $async = true;
 
+    /**
+     * The id of the db record of the file
+     * @var int|null
+     */
+    protected ?int $fileId = null;
+
     /**
      * @return int
      */
@@ -97,4 +103,20 @@ abstract class ExportRequest
     {
         $this->async = $async;
     }
+
+    /**
+     * @return int|null
+     */
+    public function getFileId(): ?int
+    {
+        return $this->fileId;
+    }
+
+    /**
+     * @param int|null $fileId
+     */
+    public function setFileId(?int $fileId): void
+    {
+        $this->fileId = $fileId;
+    }
 }

+ 2 - 4
src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php

@@ -18,11 +18,9 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[ApiResource(
     collectionOperations: [
         'post' => [
-            'security' => '(is_granted("ROLE_ADMIN_CORE") or 
-                            is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE") or 
-                           ) and object.getOrganizationId() == user.getOrganization().getId()',
+            'security' => '(is_granted("ROLE_ADMIN_CORE") or is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE"))',
             'method' => 'POST',
-            'path' => '/licence-cmf/organization',
+            'path' => '/cmf-licence/organization',
         ],
     ],
     routePrefix: '/export'

+ 32 - 18
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -6,19 +6,23 @@ namespace App\DataPersister\Export\LicenceCmf;
 use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Enum\Core\FileStatusEnum;
 use App\Message\Command\Export;
 use App\Service\ServiceIterator\ExporterIterator;
+use Doctrine\ORM\EntityManagerInterface;
 use Exception;
+use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Security\Core\Security;
-use Symfony\Component\HttpFoundation\Response;
 
 class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
 {
     public function __construct(
-        private Security $security,
-        private MessageBusInterface $messageBus,
-        private ExporterIterator $handler
+        private EntityManagerInterface $em,
+        private Security               $security,
+        private MessageBusInterface    $messageBus,
+        private ExporterIterator       $handler
     ) {}
 
     public function supports($data, array $context = []): bool
@@ -29,30 +33,40 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
     /**
      * @param $exportRequest ExportRequest Une requête d'export
      * @param array $context
-     * @return Response
+     * @return JsonResponse
      * @throws Exception
      */
-    public function persist($exportRequest, array $context = []): Response
+    public function persist($exportRequest, array $context = []): File
     {
         /** @var Access $access */
         $access = $this->security->getUser();
         $exportRequest->setRequesterId($access->getId());
 
-        if ($exportRequest->isAsync()) {
+        $file = new File();
+        $file->setOrganization($access->getOrganization());
+        $file->setVisibility('NOBODY');
+        $file->setFolder('DOCUMENTS');
+        $file->setCreateDate(new \DateTime());
+        $file->setCreatedBy($access->getId());
+        $file->setStatus(FileStatusEnum::PENDING()->getValue());
+        $file->setName('');
+        $file->setPath('');
+        $file->setSlug('');
+        $file->setIsTemporaryFile(true);
+        $this->em->persist($file);
+        $this->em->flush();
 
-            // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
-            $this->messageBus->dispatch(
-                new Export($exportRequest)
-            );
-            return new Response(null, 204);
+        $exportRequest->setFileId($file->getId());
 
-        } else {
-
-            $exportService = $this->handler->getExporterFor($exportRequest);
-            $file = $exportService->export($exportRequest);
-
-            return new Response('File generated: ' . $file->getId(), 200);
+        if (!$exportRequest->isAsync()) {
+            return $this->handler->getExporterFor($exportRequest)->export($exportRequest);
         }
+
+        // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
+        $this->messageBus->dispatch(
+            new Export($exportRequest)
+        );
+        return $file;
     }
 
     /**

+ 3 - 2
src/Doctrine/Core/CurrentNotificationUserExtension.php

@@ -10,7 +10,8 @@ use Doctrine\ORM\QueryBuilder;
 use Symfony\Component\Security\Core\Security;
 
 /**
- * Class NotificationExtension : Filtre de sécurité par défaut pour une resource Notification
+ * Filtre de sécurité par défaut pour une resource NotificationUser
+ *
  * @package App\Doctrine\Core
  */
 final class CurrentNotificationUserExtension extends AbstractExtension
@@ -35,4 +36,4 @@ final class CurrentNotificationUserExtension extends AbstractExtension
             ->setParameter('current_access', $currentUser)
         ;
     }
-}
+}

+ 26 - 0
src/Entity/Core/File.php

@@ -22,6 +22,8 @@ use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use JetBrains\PhpStorm\Pure;
 use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints as Assert;
+use App\Enum\Core\FileStatusEnum;
 
 #[ApiResource(
     collectionOperations: [],
@@ -156,6 +158,14 @@ class File
     #[ORM\Column]
     private ?int $updatedBy;
 
+    /**
+     * Statut du fichier (en cours de génération, prêt, supprimé, etc.)
+     * @var string | null
+     */
+    #[ORM\Column(length: 50)]
+    #[Assert\Choice(callback: [FileStatusEnum::class, 'toArray'])]
+    private ?string $status = null;
+
 //    #[ORM\Column]
 //    private ?int $eventReport_id;
 //
@@ -498,6 +508,22 @@ class File
         $this->updatedBy = $updatedBy;
     }
 
+    /**
+     * @return string|null
+     */
+    public function getStatus(): ?string
+    {
+        return $this->status;
+    }
+
+    /**
+     * @param string|null $status
+     */
+    public function setStatus(?string $status): void
+    {
+        $this->status = $status;
+    }
+
     public function getOrganizationLogos(): Collection
     {
         return $this->organizationLogos;

+ 64 - 14
src/Entity/Core/Notification.php

@@ -13,6 +13,7 @@ use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use JetBrains\PhpStorm\Pure;
 use Symfony\Component\Validator\Constraints as Assert;
+use App\Enum\Core\NotificationTypeEnum;
 
 /**
  * @todo : A la suite de la migration, il faut supprimer le nom de la table pour avoir une table Notification, et supprimer l'attribut discr.
@@ -56,11 +57,17 @@ class Notification
     #[ORM\Column(length: 40, nullable: true)]
     private ?string $name = null;
 
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    private ?\DateTimeInterface $createDate;
+
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    private ?\DateTimeInterface $updateDate;
+
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
     private mixed $message = [];
 
     #[ORM\Column(nullable: true)]
-    #[Assert\Choice(callback: ['\App\Enum\Core\NotificationTypeEnum', 'toArray'], message: 'invalid-type')]
+    #[Assert\Choice(callback: [NotificationTypeEnum::class, 'toArray'], message: 'invalid-type')]
     private ?string $type = null;
 
     #[ORM\Column(length: 255, nullable: true)]
@@ -104,6 +111,61 @@ class Notification
         return $this->recipientAccess;
     }
 
+    /**
+     * @return Organization|null
+     */
+    public function getRecipientOrganization(): ?Organization
+    {
+        return $this->recipientOrganization;
+    }
+
+    /**
+     * @param Organization|null $recipientOrganization
+     * @return Notification
+     */
+    public function setRecipientOrganization(?Organization $recipientOrganization): self
+    {
+        $this->recipientOrganization = $recipientOrganization;
+
+        return $this;
+    }
+
+    /**
+     * @return \DateTimeInterface|null
+     */
+    public function getCreateDate(): ?\DateTimeInterface
+    {
+        return $this->createDate;
+    }
+
+    /**
+     * @param \DateTimeInterface|null $createDate
+     */
+    public function setCreateDate(?\DateTimeInterface $createDate): self
+    {
+        $this->createDate = $createDate;
+
+        return $this;
+    }
+
+    /**
+     * @return \DateTimeInterface|null
+     */
+    public function getUpdateDate(): ?\DateTimeInterface
+    {
+        return $this->updateDate;
+    }
+
+    /**
+     * @param \DateTimeInterface|null $updateDate
+     */
+    public function setUpdateDate(?\DateTimeInterface $updateDate): self
+    {
+        $this->updateDate = $updateDate;
+
+        return $this;
+    }
+
     public function setMessage(mixed $message): self
     {
         $this->message = $message;
@@ -188,16 +250,4 @@ class Notification
 
         return $this;
     }
-
-    public function getRecipientOrganization(): ?Organization
-    {
-        return $this->recipientOrganization;
-    }
-
-    public function setRecipientOrganization(?Organization $recipientOrganization): self
-    {
-        $this->recipientOrganization = $recipientOrganization;
-
-        return $this;
-    }
-}
+}

+ 2 - 2
src/Entity/Core/NotificationUser.php

@@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM;
 
 /**
  *
- * Classe NotificationUser. qui permet de gérer les notifications qui ont été lues par les Users.
+ * Les NotificationUser permettent de garder la trace des notifications qui ont été lues par les utilisateurs
  */
 #[ApiResource(
     collectionOperations: [
@@ -79,4 +79,4 @@ class NotificationUser
     {
         return $this->isRead;
     }
-}
+}

+ 18 - 0
src/Enum/Core/FileStatusEnum.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Enum\Core;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Statuts des fichiers
+ * @method static PENDING()
+ * @method static READY()
+ */
+class FileStatusEnum extends Enum
+{
+    private const PENDING = 'PENDING';
+    private const READY = 'READY';
+    private const DELETED = 'DELETED';
+    private const ERROR = 'ERROR';
+}

+ 5 - 1
src/Enum/Core/NotificationTypeEnum.php

@@ -8,6 +8,10 @@ use MyCLabs\Enum\Enum;
 /**
  * Type de notifications
  *
+ * @method static FILE()
+ * @method static MESSAGE()
+ * @method static SYSTEM()
+ * @method static ERROR()
  */
 class NotificationTypeEnum extends Enum
 {
@@ -16,4 +20,4 @@ class NotificationTypeEnum extends Enum
     private const MESSAGE = 'MESSAGE';
     private const PRINTING = 'PRINTING';
     private const ERROR = 'ERROR';
-}
+}

+ 17 - 2
src/Message/Handler/ExportHandler.php

@@ -4,19 +4,34 @@ declare(strict_types=1);
 namespace App\Message\Handler;
 
 use App\Message\Command\Export;
+use App\Repository\Access\AccessRepository;
+use App\Service\MercureHub;
+use App\Service\Notifier;
 use App\Service\ServiceIterator\ExporterIterator;
 use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
 
 class ExportHandler implements MessageHandlerInterface
 {
     public function __construct(
-        private ExporterIterator $handler
+        private ExporterIterator $handler,
+        private MercureHub $mercureHub,
+        private AccessRepository $accessRepository,
+        private Notifier $notifier
     ) {}
 
     public function __invoke(Export $export)
     {
         $exportRequest = $export->getExportRequest();
         $exportService = $this->handler->getExporterFor($exportRequest);
-        $exportService->export($exportRequest);
+        $file = $exportService->export($exportRequest);
+
+        $this->mercureHub->publishUpdate($exportRequest->getRequesterId(), $file);
+
+        $this->notifier->notifyExport(
+            $this->accessRepository->find($exportRequest->getRequesterId()),
+            $file
+        );
     }
+
+
 }

+ 40 - 24
src/Service/Export/BaseExporter.php

@@ -5,8 +5,10 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Core\File;
+use App\Enum\Core\FileStatusEnum;
 use App\Enum\Export\ExportFormatEnum;
 use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
 use App\Service\Export\Model\ExportModelInterface;
 use App\Service\ServiceIterator\EncoderIterator;
 use App\Service\Storage\TemporaryFileStorage;
@@ -24,6 +26,7 @@ abstract class BaseExporter implements ExporterInterface
 {
     // dependencies injections
     protected AccessRepository $accessRepository;
+    protected FileRepository $fileRepository;
     protected Environment $twig;
     protected EncoderIterator $encoderIterator;
     protected EntityManagerInterface $entityManager;
@@ -31,17 +34,26 @@ abstract class BaseExporter implements ExporterInterface
     protected LoggerInterface $logger;
 
     #[Required]
-    public function setAccessRepository(AccessRepository $accessRepository) { $this->accessRepository = $accessRepository; }
+    public function setAccessRepository(AccessRepository $accessRepository): void
+    { $this->accessRepository = $accessRepository; }
     #[Required]
-    public function setTwig(Environment $twig) { $this->twig = $twig; }
+    public function setFileRepository(FileRepository $fileRepository): void
+    { $this->fileRepository = $fileRepository; }
     #[Required]
-    public function setEncoderIterator(EncoderIterator $encoderIterator) { $this->encoderIterator = $encoderIterator; }
+    public function setTwig(Environment $twig): void
+    { $this->twig = $twig; }
     #[Required]
-    public function setEntityManager(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; }
+    public function setEncoderIterator(EncoderIterator $encoderIterator): void
+    { $this->encoderIterator = $encoderIterator; }
     #[Required]
-    public function setStorage(TemporaryFileStorage $storage) { $this->storage = $storage; }
+    public function setEntityManager(EntityManagerInterface $entityManager): void
+    { $this->entityManager = $entityManager; }
     #[Required]
-    public function setLogger(LoggerInterface $logger) { $this->logger = $logger; }
+    public function setStorage(TemporaryFileStorage $storage): void
+    { $this->storage = $storage; }
+    #[Required]
+    public function setLogger(LoggerInterface $logger): void
+    { $this->logger = $logger; }
 
     public function support(ExportRequest $exportRequest): bool
     {
@@ -75,26 +87,31 @@ abstract class BaseExporter implements ExporterInterface
 
         $path = $this->store($filename, $content);
 
-        // Met à jour l'enregistrement du fichier en base
-        // <-- [refactoring] cette partie pourrait être faite en amont du service
-        $file = new File();
-
-        $requesterId = $exportRequest->getRequesterId();
-        $organization = $this->accessRepository->find($requesterId)->getOrganization();
+        // Met à jour ou créé l'enregistrement du fichier en base
+        if ($exportRequest->getFileId() !== null) {
+            $file = $this->fileRepository->find($exportRequest->getFileId());
+        } else {
+            // Todo: voir si ce else est nécessaire une fois tous les exports implémentés
+            $file = new File();
+
+            $requesterId = $exportRequest->getRequesterId();
+            $organization = $this->accessRepository->find($requesterId)?->getOrganization();
+            if ($organization === null) {
+                throw new \RuntimeException('Unable to determine the organization of the curent user; abort.');
+            }
+            $file->setOrganization($organization);
+            $file->setVisibility('NOBODY');
+            $file->setFolder('DOCUMENTS');
+            $file->setCreateDate(new \DateTime());
+            $file->setCreatedBy($requesterId);
+        }
 
-        $file->setOrganization($organization);
-        $file->setVisibility('NOBODY');
-        $file->setFolder('DOCUMENTS');
-        $file->setCreateDate(new \DateTime());
-        $file->setCreatedBy($requesterId);
-        // -->
-        // <-- [refactoring] cette partie doit être faite après la création du fichier (storage ? service ?)
         $file->setType($this->getFileType());
         $file->setMimeType(ExportFormatEnum::getMimeType($exportRequest->getFormat()));
         $file->setName($filename);
         $file->setPath($path);
         $file->setSlug($path);
-        // -->
+        $file->setStatus(FileStatusEnum::READY()->getValue());
 
         $this->entityManager->persist($file);
         $this->entityManager->flush();
@@ -112,7 +129,7 @@ abstract class BaseExporter implements ExporterInterface
      */
     protected function buildModel(ExportRequest $exportRequest): ExportModelInterface
     {
-        throw new Exception('not implemented error');
+        throw new \RuntimeException('not implemented error');
     }
 
     /**
@@ -128,7 +145,7 @@ abstract class BaseExporter implements ExporterInterface
         $classname = end($arr);
         return StringsUtils::camelToSnake(
             preg_replace(
-                '/^([\w\d]+)Exporter$/',
+                '/^([\w]+)Exporter$/',
                 '$1',
                 $classname,
                 1)
@@ -173,8 +190,7 @@ abstract class BaseExporter implements ExporterInterface
      */
     protected function encode(string $html, string $format): string
     {
-        $encoder = $this->encoderIterator->getEncoderFor($format);
-        return $encoder->encode($html);
+        return $this->encoderIterator->getEncoderFor($format)->encode($html);
     }
 
     /**

+ 2 - 1
src/Service/Export/ExporterInterface.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
+use App\Entity\Core\File;
 
 /**
  * Classe de base des services d'export
@@ -24,5 +25,5 @@ interface ExporterInterface
      *
      * @param ExportRequest $exportRequest
      */
-    public function export(ExportRequest $exportRequest);
+    public function export(ExportRequest $exportRequest): File;
 }

+ 14 - 8
src/Service/Export/LicenceCmfExporter.php

@@ -15,15 +15,15 @@ use App\Service\Storage\UploadStorage;
 /**
  * Exporte la licence CMF de la structure ou du ou des access, au format demandé
  */
-class LicenceCmfExporter extends BaseExporter implements ExporterInterface
+class LicenceCmfExporter extends BaseExporter
 {
-    const CMF_ID = 12097;
+    public const CMF_ID = 12097;
 
     /**
      * La couleur de la carte de licence change chaque année, de manière cyclique
      */
-    const LICENCE_CMF_COLOR_START_YEAR = "2020";
-    const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
+    public const LICENCE_CMF_COLOR_START_YEAR = "2020";
+    public const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
 
     public function __construct(
         private OrganizationRepository $organizationRepository,
@@ -38,7 +38,10 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
 
     protected function buildModel(ExportRequest $exportRequest): LicenceCmfCollection
     {
-        $organization = $this->accessRepository->find($exportRequest->getRequesterId())->getOrganization();
+        $organization = $this->accessRepository->find($exportRequest->getRequesterId())?->getOrganization();
+        if ($organization === null) {
+            throw new \RuntimeException('Unable to dermin the organization of the curent user; abort.');
+        }
 
         $licenceCmf = new LicenceCmf();
         $licenceCmf->setId($organization->getId());
@@ -47,8 +50,10 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
         $licenceCmf->setOrganizationName($organization->getName());
         $licenceCmf->setOrganizationIdentifier($organization->getIdentifier());
 
-        $parentFederation = $organization->getNetworkOrganizations()->get(0)->getParent();
-        $licenceCmf->setFederationName($parentFederation->getName());
+        $parentFederation = $organization->getNetworkOrganizations()->get(0)?->getParent();
+        if ($parentFederation !== null) {
+            $licenceCmf->setFederationName($parentFederation->getName());
+        }
 
         $licenceCmf->setColor(
             $this->getLicenceColor($exportRequest->getYear())
@@ -71,6 +76,7 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
         }
 
         $cmf = $this->organizationRepository->find(self::CMF_ID);
+        /** @noinspection NullPointerExceptionInspection */
         $qrCodeId = $cmf->getParameters()?->getQrCode()?->getId();
         if ($qrCodeId) {
             $licenceCmf->setQrCodeUri(
@@ -108,7 +114,7 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
      * @return string
      */
     protected function getLicenceColor(int $year): string {
-        if (!self::LICENCE_CMF_COLOR_START_YEAR > $year) {
+        if (!(self::LICENCE_CMF_COLOR_START_YEAR > $year)) {
             return self::LICENCE_CMF_COLOR[0];
         }
         return self::LICENCE_CMF_COLOR[($year - self::LICENCE_CMF_COLOR_START_YEAR) % count(self::LICENCE_CMF_COLOR)];

+ 89 - 0
src/Service/MercureHub.php

@@ -0,0 +1,89 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+use ApiPlatform\Core\Api\IriConverterInterface;
+use Symfony\Component\Mercure\HubInterface;
+use Symfony\Component\Mercure\Update;
+use Symfony\Component\Serializer\Encoder\EncoderInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Sends private and encrypted mercure updates to the target users.
+ *
+ * Updates inform of modifications on entities : updates, creations, deletions.
+ *
+ * The update topic is the id of the recipient user.
+ * The content is a json containing the iri of the entity, the operation type, and the current data of this entity
+ */
+class MercureHub
+{
+    public const UPDATE = 'update';
+    public const CREATE = 'create';
+    public const DELETE = 'delete';
+
+    public function __construct(
+        private HubInterface          $hub,
+        private SerializerInterface   $serializer,
+        private EncoderInterface      $encoder,
+        private IriConverterInterface $iriConverter
+    ) {}
+
+    protected function createUpdate($accessId, string $data): Update
+    {
+        return new Update(
+            "access/{$accessId}",
+            $data,
+            true
+        );
+    }
+
+    /**
+     * Send an update to the
+     *
+     * @param $entity
+     * @param int $accessId
+     * @param string $operationType
+     */
+    public function publish(int $accessId, $entity, string $operationType = self::UPDATE): void
+    {
+        if (!in_array($operationType, [self::UPDATE, self::CREATE, self::DELETE], true)) {
+            throw new \InvalidArgumentException('Invalid operation type');
+        }
+
+        $data = $this->encoder->encode([
+            'iri' => $this->iriConverter->getIriFromItem($entity),
+            'operation' => $operationType,
+            'data' => $this->serializer->serialize($entity, 'jsonld')
+        ], 'jsonld');
+
+        $this->hub->publish(
+            $this->createUpdate($accessId, $data)
+        );
+    }
+
+    /**
+     * @param $entity
+     * @param int $accessId
+     */
+    public function publishUpdate(int $accessId, $entity): void {
+        $this->publish($accessId, $entity, self::UPDATE);
+    }
+
+    /**
+     * @param $entity
+     * @param int $accessId
+     */
+    public function publishCreate(int $accessId, $entity): void {
+        $this->publish($accessId, $entity, self::CREATE);
+    }
+
+    /**
+     * @param $entity
+     * @param int $accessId
+     */
+    public function publishDelete(int $accessId, $entity): void {
+        $this->publish($accessId, $entity, self::DELETE);
+    }
+}

+ 112 - 0
src/Service/Notifier.php

@@ -0,0 +1,112 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Core\Notification;
+use App\Enum\Core\NotificationTypeEnum;
+use Doctrine\ORM\EntityManagerInterface;
+
+class Notifier
+{
+    public function __construct(
+        private EntityManagerInterface $em,
+        private MercureHub $mercureHub
+    ) {}
+
+    protected function createNotification(
+        Access $access,
+        string $name,
+        string $type,
+        array $message,
+        string $link = null
+    ): Notification {
+        $now = new \DateTime();
+
+        $notification = new Notification();
+        $notification->setName($name)
+            ->setRecipientAccess($access)
+            ->setRecipientOrganization($access->getOrganization())
+            ->setType($type)
+            ->setMessage($message)
+            ->setLink($link)
+            ->setCreateDate($now)
+            ->setUpdateDate($now);
+        return $notification;
+    }
+
+    public function notify(
+        Access $access,
+        string $name,
+        string $type,
+        array $message,
+        string $link = null
+    ): Notification
+    {
+        $notification = $this->createNotification($access, $name, $type, $message, $link);
+
+        $this->em->persist($notification);
+        $this->em->flush();
+
+        $this->mercureHub->publishCreate($access->getId(), $notification);
+
+        return $notification;
+    }
+
+    public function notifyExport(
+        Access $access,
+        File $file
+    ): Notification
+    {
+        return $this->notify(
+            $access,
+            'export',
+            NotificationTypeEnum::FILE()->getValue(),
+            ['fileName' => $file->getName()],
+            '/api/files/' . $file->getId() . '/download'
+        );
+    }
+
+    public function notifyMessage(
+        Access $access,
+        array $message
+    ): Notification
+    {
+        return $this->notify(
+            $access,
+            'message',
+            NotificationTypeEnum::MESSAGE()->getValue(),
+            $message
+        );
+    }
+
+    public function notifySystem(
+        Access $access,
+        array $message
+    ): Notification
+    {
+        return $this->notify(
+            $access,
+            'message',
+            NotificationTypeEnum::SYSTEM()->getValue(),
+            $message
+        );
+    }
+
+    public function notifyError(
+        Access $access,
+        string $name,
+        array $message
+    ): Notification
+    {
+        return $this->notify(
+            $access,
+            $name,
+            NotificationTypeEnum::ERROR()->getValue(),
+            $message
+        );
+    }
+
+}

+ 4 - 4
src/Service/ServiceIterator/CurrentAccessExtensionIterator.php

@@ -5,17 +5,17 @@ namespace App\Service\ServiceIterator;
 
 use App\Doctrine\Access\AccessExtensionInterface;
 use Doctrine\ORM\QueryBuilder;
-use Exception;
 
-class CurrentAccessExtensionIterator{
+class CurrentAccessExtensionIterator {
     public function __construct(private iterable $extensions)
     { }
 
-    public function addWhere(QueryBuilder $queryBuilder, $operationName){
+    public function addWhere(QueryBuilder $queryBuilder, $operationName) {
         /** @var AccessExtensionInterface $extension */
         foreach ($this->extensions as $extension){
-            if($extension->support($operationName))
+            if($extension->support($operationName)) {
                 return $extension->addWhere($queryBuilder);
+            }
         }
     }
 }

+ 15 - 0
symfony.lock

@@ -406,6 +406,21 @@
             "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
         }
     },
+    "symfony/mercure": {
+        "version": "v0.6.1"
+    },
+    "symfony/mercure-bundle": {
+        "version": "0.3",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "0.3",
+            "ref": "e0a854b5439186e04b28fb8887b42c54f24a0d32"
+        },
+        "files": [
+            "config/packages/mercure.yaml"
+        ]
+    },
     "symfony/messenger": {
         "version": "5.3",
         "recipe": {

+ 140 - 0
tests/Service/MercureHubTest.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Tests\Service;
+
+use ApiPlatform\Core\Api\IriConverterInterface;
+use App\Service\MercureHub;
+use Doctrine\Entity;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Mercure\HubInterface;
+use Symfony\Component\Mercure\Update;
+use Symfony\Component\Serializer\Encoder\EncoderInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+class TestableMercureHub extends MercureHub {
+    public function createUpdate($accessId, string $data): Update { return parent::createUpdate($accessId, $data); }
+}
+
+class MercureHubTest extends TestCase
+{
+    private HubInterface $hub;
+    private SerializerInterface $serializer;
+    private EncoderInterface $encoder;
+    private IriConverterInterface $iriConverter;
+
+    public function setUp(): void {
+        $this->hub = $this->getMockBuilder(HubInterface::class)->disableOriginalConstructor()->getMock();
+        $this->serializer = $this->getMockBuilder(SerializerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->encoder = $this->getMockBuilder(EncoderInterface::class)->disableOriginalConstructor()->getMock();
+        $this->iriConverter = $this->getMockBuilder(IriConverterInterface::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testCreateUpdate(): void {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['createUpdate'])
+            ->getMock();
+
+        $update = $mercureHub->createUpdate(1, "{'foo': 1}");
+
+        $this->assertEquals(['access/1'], $update->getTopics());
+        $this->assertEquals("{'foo': 1}", $update->getData());
+        $this->assertEquals(true, $update->isPrivate());
+    }
+
+    public function testPublish(): void
+    {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['publish'])
+            ->getMock();
+
+        $entity = $this->getMockBuilder(Entity::class)->disableOriginalConstructor()->getMock();
+
+        $this->iriConverter->expects(self::once())->method('getIriFromItem')->with($entity)->willReturn('/api/entity/1');
+        $this->serializer->expects(self::once())->method('serialize')->with($entity, 'jsonld', [])->willReturn("{'foo': 1}");
+
+        $this->encoder
+            ->expects(self::once())
+            ->method('encode')
+            ->with([
+                'iri' => '/api/entity/1',
+                'operation' => 'update',
+                'data' => "{'foo': 1}"
+            ], 'jsonld')
+            ->willReturn("{'iri':'/api/entity/1','operation':'update','data':'{\'foo\':1}'");
+
+        $update = new Update(
+            'access/1',
+            "{'iri':'/api/entity/1','operation':'update','data':'{\'foo\':1}'",
+            true
+        );
+
+        $mercureHub
+            ->expects(self::once())
+            ->method('createUpdate')
+            ->with(1, "{'iri':'/api/entity/1','operation':'update','data':'{\'foo\':1}'")
+            ->willReturn($update);
+
+        $this->hub->expects(self::once())->method('publish')->with($update);
+
+        $mercureHub->publish(1, $entity);
+    }
+
+    public function testPublishInvalidOperation(): void
+    {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['publish'])
+            ->getMock();
+
+        $entity = $this->getMockBuilder(Entity::class)->disableOriginalConstructor()->getMock();
+
+        $this->expectException(\InvalidArgumentException::class);
+        $mercureHub->publish(1, $entity, 'foo');
+    }
+
+    public function testPublishUpdate(): void {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['publishUpdate'])
+            ->getMock();
+
+        $entity = $this->getMockBuilder(Entity::class)->disableOriginalConstructor()->getMock();
+
+        $mercureHub->expects(self::once())->method('publish')->with(1, $entity, 'update');
+        $mercureHub->publishUpdate(1, $entity);
+    }
+
+    public function testPublishCreate(): void {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['publishCreate'])
+            ->getMock();
+
+        $entity = $this->getMockBuilder(Entity::class)->disableOriginalConstructor()->getMock();
+
+        $mercureHub->expects(self::once())->method('publish')->with(1, $entity, 'create');
+
+        $mercureHub->publishCreate(1, $entity);
+    }
+
+    public function testPublishDelete(): void {
+        $mercureHub = $this
+            ->getMockBuilder(TestableMercureHub::class)
+            ->setConstructorArgs([$this->hub, $this->serializer, $this->encoder, $this->iriConverter])
+            ->setMethodsExcept(['publishDelete'])
+            ->getMock();
+
+        $entity = $this->getMockBuilder(Entity::class)->disableOriginalConstructor()->getMock();
+
+        $mercureHub->expects(self::once())->method('publish')->with(1, $entity, 'delete');
+
+        $mercureHub->publishDelete(1, $entity);
+    }
+}

+ 157 - 0
tests/Service/NotifierTest.php

@@ -0,0 +1,157 @@
+<?php
+
+namespace App\Tests\Service;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Core\Notification;
+use App\Entity\Organization\Organization;
+use App\Enum\Core\NotificationTypeEnum;
+use App\Service\MercureHub;
+use App\Service\Notifier;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+
+class TestableNotifier extends Notifier {
+    public function createNotification(Access $access, string $name, string $type, array $message, string $link = null): Notification {
+        return parent::createNotification($access, $name, $type, $message, $link);
+    }
+}
+
+class NotifierTest extends TestCase
+{
+    private EntityManagerInterface $em;
+    private MercureHub $mercureHub;
+
+    public function setUp(): void {
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->mercureHub = $this->getMockBuilder(MercureHub::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testCreateNotification(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['createNotification'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getId')->willReturn(1);
+        $access->method('getOrganization')->willReturn($organization);
+
+        $notification = $notifier->createNotification(
+            $access,
+            'test',
+            NotificationTypeEnum::MESSAGE()->getValue(),
+            ['message'],
+            'link'
+        );
+
+        $this->assertEquals('test', $notification->getName());
+        $this->assertEquals($access, $notification->getRecipientAccess());
+        $this->assertEquals($organization, $notification->getRecipientOrganization());
+        $this->assertEquals(NotificationTypeEnum::MESSAGE()->getValue(), $notification->getType());
+        $this->assertEquals(['message'], $notification->getMessage());
+        $this->assertEquals('link', $notification->getLink());
+        $this->assertInstanceOf(\DateTime::class, $notification->getCreateDate());
+        $this->assertInstanceOf(\DateTime::class, $notification->getUpdateDate());
+    }
+
+    public function testNotify(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['notify'])
+            ->getMock();
+
+        $notification = $this->getMockBuilder(Notification::class)->getMock();
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getId')->willReturn(1);
+
+        $notifier
+            ->expects(self::once())
+            ->method('createNotification')
+            ->with($access, 'test', NotificationTypeEnum::MESSAGE()->getValue(), ['message'], null)
+            ->willReturn($notification);
+
+        $this->em->expects(self::once())->method('persist')->with($notification);
+        $this->em->expects(self::once())->method('flush');
+        $this->mercureHub->expects(self::once())->method('publishCreate')->with(1, $notification);
+
+        $returned = $notifier->notify($access, 'test', NotificationTypeEnum::MESSAGE()->getValue(), ['message']);
+
+        $this->assertEquals($notification, $returned);
+    }
+
+    public function testNotifyExport(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['notifyExport'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(1);
+        $file->method('getName')->willReturn('foo.txt');
+
+        $notifier
+            ->expects(self::once())
+            ->method('notify')
+            ->with($access, 'export', NotificationTypeEnum::FILE()->getValue(), ['fileName' => 'foo.txt'], '/api/files/1/download');
+
+        $notifier->notifyExport($access, $file);
+    }
+
+    public function testNotifyMessage(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['notifyMessage'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $notifier
+            ->expects(self::once())
+            ->method('notify')
+            ->with($access, 'message', NotificationTypeEnum::MESSAGE()->getValue(), ['a message']);
+
+        $notifier->notifyMessage($access, ['a message']);
+    }
+
+    public function testNotifySystem(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['notifySystem'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $notifier
+            ->expects(self::once())
+            ->method('notify')
+            ->with($access, 'message', NotificationTypeEnum::SYSTEM()->getValue(), ['a message']);
+
+        $notifier->notifySystem($access, ['a message']);
+    }
+
+    public function testNotifyError(): void {
+        $notifier = $this
+            ->getMockBuilder(TestableNotifier::class)
+            ->setConstructorArgs([$this->em, $this->mercureHub])
+            ->setMethodsExcept(['notifyError'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $notifier
+            ->expects(self::once())
+            ->method('notify')
+            ->with($access, 'test', NotificationTypeEnum::ERROR()->getValue(), ['a message']);
+
+        $notifier->notifyError($access, 'test', ['a message']);
+    }
+}