diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc086f34..a12d61e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to * [docgen] add more persons choices in docgen for course: amongst requestor (if person), resources of course (if person), and PersonResource (if person); * [docgen] add a new context with a list of activities in course * [docgen] add a comment in budget lines +* [notifications] allow to send a notification to an email address. The address receive an access link ## Test releases diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationController.php b/src/Bundle/ChillMainBundle/Controller/NotificationController.php index af9e1b2b1..f40c85ffb 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationController.php @@ -22,6 +22,7 @@ use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\NotificationRepository; use Chill\MainBundle\Security\Authorization\NotificationVoter; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -31,14 +32,19 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; +use function in_array; /** * @Route("/{_locale}/notification") */ class NotificationController extends AbstractController { + private LoggerInterface $chillLogger; + private EntityManagerInterface $em; + private LoggerInterface $logger; + private NotificationHandlerManager $notificationHandlerManager; private NotificationRepository $notificationRepository; @@ -51,6 +57,8 @@ class NotificationController extends AbstractController public function __construct( EntityManagerInterface $em, + LoggerInterface $chillLogger, + LoggerInterface $logger, Security $security, NotificationRepository $notificationRepository, NotificationHandlerManager $notificationHandlerManager, @@ -58,6 +66,8 @@ class NotificationController extends AbstractController TranslatorInterface $translator ) { $this->em = $em; + $this->logger = $logger; + $this->chillLogger = $chillLogger; $this->security = $security; $this->notificationRepository = $notificationRepository; $this->notificationHandlerManager = $notificationHandlerManager; @@ -150,6 +160,49 @@ class NotificationController extends AbstractController ]); } + /** + * @Route("/{id}/access_key", name="chill_main_notification_grant_access_by_access_key") + */ + public function getAccessByAccessKey(Notification $notification, Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + if (!$this->security->getUser() instanceof User) { + throw new AccessDeniedHttpException('You must be authenticated and a user to create a notification'); + } + + foreach (['accessKey', 'email'] as $param) { + if (!$request->query->has($param)) { + throw new BadRequestHttpException("Missing {$param} parameter"); + } + } + + if ($notification->getAccessKey() !== $request->query->getAlnum('accessKey')) { + throw new AccessDeniedHttpException('access key is invalid'); + } + + if (!in_array($request->query->get('email'), $notification->getAddressesEmails(), true)) { + return (new Response('The email address is no more associated with this notification')) + ->setStatusCode(Response::HTTP_FORBIDDEN); + } + + $notification->addAddressee($this->security->getUser()); + + $this->getDoctrine()->getManager()->flush(); + + $logMsg = '[Notification] a user is granted access to notification trough an access key'; + $context = [ + 'notificationId' => $notification->getId(), + 'email' => $request->query->get('email'), + 'user' => $this->security->getUser()->getId(), + ]; + + $this->logger->info($logMsg, $context); + $this->chillLogger->info($logMsg, $context); + + return $this->redirectToRoute('chill_main_notification_show', ['id' => $notification->getId()]); + } + /** * @Route("/inbox", name="chill_main_notification_my") */ diff --git a/src/Bundle/ChillMainBundle/Entity/Notification.php b/src/Bundle/ChillMainBundle/Entity/Notification.php index b2fe40c60..8dad39d12 100644 --- a/src/Bundle/ChillMainBundle/Entity/Notification.php +++ b/src/Bundle/ChillMainBundle/Entity/Notification.php @@ -18,6 +18,9 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use function count; +use function in_array; /** * @ORM\Entity @@ -31,15 +34,34 @@ use Symfony\Component\Validator\Constraints as Assert; */ class Notification implements TrackUpdateInterface { + /** + * @ORM\Column(type="text", nullable=false) + */ + private string $accessKey; + private array $addedAddresses = []; /** * @ORM\ManyToMany(targetEntity=User::class) * @ORM\JoinTable(name="chill_main_notification_addresses_user") - * @Assert\Count(min="1", minMessage="notification.At least one addressee") */ private Collection $addressees; + /** + * a list of destinee which will receive notifications. + * + * @var array|string[] + * @ORM\Column(type="json") + */ + private array $addressesEmails = []; + + /** + * a list of emails adresses which were added to the notification. + * + * @var array|string[] + */ + private array $addressesEmailsAdded = []; + private ?ArrayCollection $addressesOnLoad = null; /** @@ -111,6 +133,7 @@ class Notification implements TrackUpdateInterface $this->unreadBy = new ArrayCollection(); $this->comments = new ArrayCollection(); $this->setDate(new DateTimeImmutable()); + $this->accessKey = bin2hex(openssl_random_pseudo_bytes(24)); } public function addAddressee(User $addressee): self @@ -123,6 +146,14 @@ class Notification implements TrackUpdateInterface return $this; } + public function addAddressesEmail(string $email) + { + if (!in_array($email, $this->addressesEmails, true)) { + $this->addressesEmails[] = $email; + $this->addressesEmailsAdded[] = $email; + } + } + public function addComment(NotificationComment $comment): self { if (!$this->comments->contains($comment)) { @@ -142,6 +173,30 @@ class Notification implements TrackUpdateInterface return $this; } + /** + * @Assert\Callback + * + * @param array $payload + */ + public function assertCountAddresses(ExecutionContextInterface $context, $payload): void + { + if (0 === (count($this->getAddressesEmails()) + count($this->getAddressees()))) { + $context->buildViolation('notification.At least one addressee') + ->atPath('addressees') + ->addViolation(); + } + } + + public function getAccessKey(): string + { + return $this->accessKey; + } + + public function getAddedAddresses(): array + { + return $this->addedAddresses; + } + /** * @return Collection|User[] */ @@ -155,6 +210,22 @@ class Notification implements TrackUpdateInterface return $this->addressees; } + /** + * @return array|string[] + */ + public function getAddressesEmails(): array + { + return $this->addressesEmails; + } + + /** + * @return array|string[] + */ + public function getAddressesEmailsAdded(): array + { + return $this->addressesEmailsAdded; + } + public function getComments(): Collection { return $this->comments; @@ -271,6 +342,14 @@ class Notification implements TrackUpdateInterface return $this; } + public function removeAddressesEmail(string $email) + { + if (in_array($email, $this->addressesEmails, true)) { + $this->addressesEmails = array_filter($this->addressesEmails, static fn ($e) => $e !== $email); + $this->addressesEmailsAdded = array_filter($this->addressesEmailsAdded, static fn ($e) => $e !== $email); + } + } + public function removeComment(NotificationComment $comment): self { $this->comments->removeElement($comment); diff --git a/src/Bundle/ChillMainBundle/Form/NotificationType.php b/src/Bundle/ChillMainBundle/Form/NotificationType.php index b24513524..22fd19baf 100644 --- a/src/Bundle/ChillMainBundle/Form/NotificationType.php +++ b/src/Bundle/ChillMainBundle/Form/NotificationType.php @@ -12,12 +12,17 @@ declare(strict_types=1); namespace Chill\MainBundle\Form; use Chill\MainBundle\Entity\Notification; +use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\PickUserDynamicType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; class NotificationType extends AbstractType { @@ -30,9 +35,27 @@ class NotificationType extends AbstractType ]) ->add('addressees', PickUserDynamicType::class, [ 'multiple' => true, + 'required' => false, ]) ->add('message', ChillTextareaType::class, [ 'required' => false, + ]) + ->add('addressesEmails', ChillCollectionType::class, [ + 'label' => 'notification.dest by email', + 'help' => 'notification.dest by email help', + 'by_reference' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'entry_type' => EmailType::class, + 'button_add_label' => 'notification.Add an email', + 'button_remove_label' => 'notification.Remove an email', + 'empty_collection_explain' => 'notification.Any email', + 'entry_options' => [ + 'constraints' => [ + new NotNull(), new NotBlank(), new Email(['checkMX' => true]), + ], + 'label' => 'Email', + ], ]); } diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php index ce5587ed9..69fcf66f0 100644 --- a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php @@ -73,6 +73,17 @@ class NotificationMailer * Send a email after a notification is persisted. */ public function postPersistNotification(Notification $notification, LifecycleEventArgs $eventArgs): void + { + $this->sendNotificationEmailsToAddresses($notification); + $this->sendNotificationEmailsToAddressesEmails($notification); + } + + public function postUpdateNotification(Notification $notification, LifecycleEventArgs $eventArgs): void + { + $this->sendNotificationEmailsToAddressesEmails($notification); + } + + private function sendNotificationEmailsToAddresses(Notification $notification): void { foreach ($notification->getAddressees() as $addressee) { if (null === $addressee->getEmail()) { @@ -108,4 +119,31 @@ class NotificationMailer } } } + + private function sendNotificationEmailsToAddressesEmails(Notification $notification): void + { + foreach ($notification->getAddressesEmailsAdded() as $emailAddress) { + $email = new TemplatedEmail(); + $email + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') + ->context([ + 'notification' => $notification, + 'dest' => $emailAddress, + ]); + + $email + ->subject($notification->getTitle()) + ->to($emailAddress); + + try { + $this->mailer->send($email); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] could not send an email notification', [ + 'to' => $emailAddress, + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + } + } + } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig index 02a02b4d0..1024d56b3 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig @@ -40,6 +40,11 @@ {{ a|chill_entity_render_string }} {% endfor %} + {% for a in c.notification.addressesEmails %} + + {{ a }} + + {% endfor %} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig index f15380083..4dfd340b6 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig @@ -20,7 +20,9 @@ {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} - + + {{ form_row(form.addressesEmails) }} + {% include handler.template(notification) with handler.templateData(notification) %}
diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig index b51cc4dab..fd3b3b6bf 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig @@ -21,6 +21,8 @@ {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} + {{ form_row(form.addressesEmails) }} + {% include handler.template(notification) with handler.templateData(notification) %}
diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig new file mode 100644 index 000000000..9a32f0c15 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig @@ -0,0 +1,20 @@ +{{ dest }}, + +{{ notification.sender.label }} a créé une notification pour vous: + +> {{ notification.title }} +> +> +{%- for line in notification.message|split("\n") %} +> {{ line }} +{%- if not loop.last %} +> +{%- endif %} +{%- endfor %} + +Vous pouvez cliquer sur ce lien pour obtenir un accès permanent à la notification: + +{{ absolute_url(path('chill_main_notification_grant_access_by_access_key', {'_locale': 'fr', 'id': notification.id, 'accessKey': notification.accessKey, 'email': dest})) }} + +-- +Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php index 8110fa11d..0575fa52a 100644 --- a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php @@ -88,6 +88,24 @@ final class NotificationTest extends KernelTestCase $this->assertNotContains($user1, $notification->getUnreadBy()->toArray()); } + public function testAddressesEmail(): void + { + $notification = new Notification(); + + $notification->addAddressesEmail('test'); + $notification->addAddressesEmail('other'); + + $this->assertContains('test', $notification->getAddressesEmails()); + $this->assertContains('other', $notification->getAddressesEmails()); + $this->assertContains('test', $notification->getAddressesEmailsAdded()); + $this->assertContains('other', $notification->getAddressesEmailsAdded()); + + $notification->removeAddressesEmail('other'); + + $this->assertNotContains('other', $notification->getAddressesEmails()); + $this->assertNotContains('other', $notification->getAddressesEmailsAdded()); + } + /** * @dataProvider generateNotificationData */ diff --git a/src/Bundle/ChillMainBundle/config/services/notification.yaml b/src/Bundle/ChillMainBundle/config/services/notification.yaml index b972dced7..544f5544c 100644 --- a/src/Bundle/ChillMainBundle/config/services/notification.yaml +++ b/src/Bundle/ChillMainBundle/config/services/notification.yaml @@ -61,6 +61,15 @@ services: # set the 'lazy' option to TRUE to only instantiate listeners when they are used lazy: true method: 'postPersistNotification' + + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\MainBundle\Entity\Notification' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'postUpdateNotification' + - name: 'doctrine.orm.entity_listener' event: 'postPersist' diff --git a/src/Bundle/ChillMainBundle/migrations/Version20220413154743.php b/src/Bundle/ChillMainBundle/migrations/Version20220413154743.php new file mode 100644 index 000000000..174419cda --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20220413154743.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE chill_main_notification DROP adressesEmails'); + $this->addSql('ALTER TABLE chill_main_notification DROP accessKey'); + } + + public function getDescription(): string + { + return 'add access keys and emails dest to notifications'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_notification ADD addressesEmails JSON NOT NULL DEFAULT \'[]\';'); + $this->addSql('ALTER TABLE chill_main_notification ADD accessKey TEXT DEFAULT NULL'); + $this->addSql('WITH randoms AS (select + n.id, + string_agg(substr(characters, (random() * length(characters) + 0.5)::integer, 1), \'\') as random_word + from (values(\'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\')) as symbols(characters) + -- length of word + join generate_series(1, 16) on 1 = 1 + JOIN chill_main_notification n ON true + GROUP BY n.id) + UPDATE chill_main_notification SET accessKey = randoms.random_word FROM randoms WHERE chill_main_notification.id = randoms.id'); + $this->addSql('ALTER TABLE chill_main_notification ALTER accessKey DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_main_notification ALTER accessKey SET NOT NULL'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 685a0c373..452ad306c 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -450,4 +450,10 @@ notification: subject: Objet see_comments_thread: Voir le fil de commentaires associé object_prefix: "[CHILL] notification - " + dest by email: Lien d'accès par email + Any email: Aucun email + Add an email: Ajouter un email + dest by email help: Les adresses email mentionnées ici recevront un lien d'accès. Un compte utilisateur sera toujours nécessaire. + Remove an email: Supprimer l'adresse email + Email with access link: Adresse email ayant reçu un lien d'accès