From ab8da4ab7a4342143b2e6f4a77f8c2580123cc77 Mon Sep 17 00:00:00 2001 From: LenaertsJ Date: Sun, 20 Jul 2025 20:18:49 +0000 Subject: [PATCH] =?UTF-8?q?Resolve=20"Notification:=20envoi=20=C3=A0=20des?= =?UTF-8?q?=20groupes=20utilisateurs"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .junie/guidelines.md | 43 +++++ config/packages/messenger.yaml | 2 + .../CalendarForShortMessageProvider.php | 6 +- .../CalendarForShortMessageProviderTest.php | 23 ++- .../ChillMainBundle/ChillMainBundle.php | 3 + .../Controller/NotificationController.php | 4 +- .../Controller/UserProfileController.php | 29 +-- .../ChillMainBundle/Entity/Notification.php | 106 +++++++++-- src/Bundle/ChillMainBundle/Entity/User.php | 62 +++++++ .../DataMapper/NotificationFlagDataMapper.php | 75 ++++++++ .../ChillMainBundle/Form/NotificationType.php | 30 +-- .../Form/Type/NotificationFlagsType.php | 63 +++++++ .../ChillMainBundle/Form/UserProfileType.php | 41 +++++ .../Email/DailyNotificationDigestCronjob.php | 102 ++++++++++ ...ScheduleDailyNotificationDigestHandler.php | 75 ++++++++ .../SendImmediateNotificationEmailHandler.php | 68 +++++++ ...ScheduleDailyNotificationDigestMessage.php | 36 ++++ .../SendImmediateNotificationEmailMessage.php | 30 +++ .../Notification/Email/NotificationMailer.php | 174 ++++++++++++++---- .../NotificationByUserFlagProvider.php | 30 +++ .../NotificationFlagProviderInterface.php | 21 +++ ...flowTransitionNotificationFlagProvider.php | 30 +++ .../Notification/NotificationFlagManager.php | 33 ++++ .../Repository/NotificationRepository.php | 37 +++- .../bootstrap_5_horizontal_layout.html.twig | 3 +- .../views/Notification/_list_item.html.twig | 37 ++-- .../views/Notification/create.html.twig | 2 - .../email_daily_digest.fr.md.twig | 24 +++ .../Resources/views/User/profile.html.twig | 30 ++- .../Tests/Entity/NotificationTest.php | 30 ++- ...otificationDigestCronJobFunctionalTest.php | 46 +++++ .../DailyNotificationDigestCronJobTest.php | 81 ++++++++ .../Email/NotificationMailerTest.php | 145 ++++++++++++++- .../NotificationOnTransition.php | 4 +- .../ChillMainBundle/config/services/form.yaml | 5 + .../config/services/notification.yaml | 12 +- .../migrations/Version20250610102953.php | 37 ++++ .../migrations/Version20250618115938.php | 37 ++++ .../migrations/Version20250623120824.php | 55 ++++++ .../translations/messages+intl-icu.fr.yaml | 6 + .../translations/messages.fr.yml | 27 ++- .../PersonAddressMoveEventSubscriber.php | 4 +- .../Events/UserRefEventSubscriber.php | 4 +- .../ChillPersonBundle/ChillPersonBundle.php | 3 + ...gnatedReferrerNotificationFlagProvider.php | 31 ++++ ...sonAddressMoveNotificationFlagProvider.php | 31 ++++ .../config/services/notification.yaml | 6 + 47 files changed, 1635 insertions(+), 148 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php create mode 100644 src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php create mode 100644 src/Bundle/ChillMainBundle/Form/UserProfileType.php create mode 100644 src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php create mode 100644 src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php create mode 100644 src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php create mode 100644 src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php create mode 100644 src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php create mode 100644 src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php create mode 100644 src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationFlagProviderInterface.php create mode 100644 src/Bundle/ChillMainBundle/Notification/FlagProviders/WorkflowTransitionNotificationFlagProvider.php create mode 100644 src/Bundle/ChillMainBundle/Notification/NotificationFlagManager.php create mode 100644 src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig create mode 100644 src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20250610102953.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20250618115938.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20250623120824.php create mode 100644 src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php create mode 100644 src/Bundle/ChillPersonBundle/Notification/FlagProviders/PersonAddressMoveNotificationFlagProvider.php diff --git a/.junie/guidelines.md b/.junie/guidelines.md index eace2f4fa..97a2be27d 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -185,14 +185,57 @@ When we need to use a DateTime or DateTimeImmutable that need to express "now", `Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, where injection does not work when restoring an entity from database, but usually possible in services. +In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface` +where we have full and easy control of the date. + ### Testing Information The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level. +#### Use of mock in tests + +##### General mocking + For creating mock, we prefer using prophecy (library phpspec/prophecy). +##### Useful helpers and tips that avoid create a mock + +Some notable implementations that are tests helper, and avoid to create a mock: + +- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`; +- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); +- `\Symfony\Component\HttpClient\MockHttpClient`, an implementation of `\Symfony\Contracts\HttpClient\HttpClientInterface`; +- When using `\Symfony\Component\Mailer\MailerInterface`, we can create the mock with "InMemoryTransport": + + ```php + use Symfony\Component\Mailer\Transport\InMemoryTransport; + use \Symfony\Component\Mailer\Mailer; + + $transport = new InMemoryTransport(); + $mailer = new Mailer($transport); + + // After sending: + $messages = $transport->getSent(); // array of SentMessage + ``` +- When using `\Symfony\Contracts\EventDispatcher\EventDispatcherInterface`, we can use directly an instance of `\Symfony\Component\EventDispatcher\EventDispatcher`; + +##### When we prefer not creating a mock + +- When we use Doctrine Entities related to the project, we prefer not to use a mock: we instantiate them directly (unless it requires too much code to write); + +##### Mocking final and readonly classes + +Classes marked as final can't be mocked. To avoid that, either: + +- we remove the `final` keyword from the class; +- we extract an interface from the final class. + +This must be a decision made by a human, not by an AI. Every AI task must abort with an explicit message in that case. + #### Running Tests +The tests are run from the project's root (not from the bundle's root). + ```bash # Run all tests vendor/bin/phpunit diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 39eab3875..4274aeec6 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -62,8 +62,10 @@ framework: 'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async + 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage': async 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async + 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async # end of routes added by chill-bundles recipes # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php index 31b870ed4..1820aa3bf 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -24,7 +24,11 @@ use Doctrine\ORM\EntityManagerInterface; class CalendarForShortMessageProvider { - public function __construct(private readonly CalendarRepository $calendarRepository, private readonly EntityManagerInterface $em, private readonly RangeGeneratorInterface $rangeGenerator) {} + public function __construct( + private readonly CalendarRepository $calendarRepository, + private readonly EntityManagerInterface $em, + private readonly RangeGeneratorInterface $rangeGenerator, + ) {} /** * Generate calendars instance. diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php index 47af7d68e..79f06b434 100644 --- a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -21,7 +21,6 @@ namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider; -use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator; use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -82,10 +81,16 @@ final class CalendarForShortMessageProviderTest extends TestCase $em = $this->prophesize(EntityManagerInterface::class); $em->clear()->shouldBeCalled(); + $calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $calendarRangeGenerator->generateRange(Argument::any())->willReturn([ + 'startDate' => new \DateTimeImmutable('yesterday'), + 'endDate' => new \DateTimeImmutable('now'), + ]); + $provider = new CalendarForShortMessageProvider( $calendarRepository->reveal(), $em->reveal(), - new DefaultRangeGenerator() + $calendarRangeGenerator->reveal(), ); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); @@ -103,26 +108,32 @@ final class CalendarForShortMessageProviderTest extends TestCase Argument::type(\DateTimeImmutable::class), Argument::type('int'), Argument::exact(0) - )->will(static fn ($args) => array_fill(0, 1, new Calendar()))->shouldBeCalledTimes(1); + )->will(static fn ($args) => array_fill(0, 10, new Calendar()))->shouldBeCalledTimes(1); $calendarRepository->findByNotificationAvailable( Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class), Argument::type('int'), - Argument::not(0) + Argument::exact(10) )->will(static fn ($args) => [])->shouldBeCalledTimes(1); $em = $this->prophesize(EntityManagerInterface::class); $em->clear()->shouldBeCalled(); + $calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $calendarRangeGenerator->generateRange(Argument::any())->willReturn([ + 'startDate' => new \DateTimeImmutable('yesterday'), + 'endDate' => new \DateTimeImmutable('now'), + ]); + $provider = new CalendarForShortMessageProvider( $calendarRepository->reveal(), $em->reveal(), - new DefaultRangeGenerator() + $calendarRangeGenerator->reveal(), ); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); - $this->assertEquals(1, \count($calendars)); + $this->assertEquals(10, \count($calendars)); $this->assertContainsOnly(Calendar::class, $calendars); } } diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 07d794ee5..4a059662c 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -20,6 +20,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompiler use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; +use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface; use Chill\MainBundle\Notification\NotificationHandlerInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Search\SearchApiInterface; @@ -61,6 +62,8 @@ class ChillMainBundle extends Bundle ->addTag('chill_main.entity_info_provider'); $container->registerForAutoconfiguration(ProvideRoleInterface::class) ->addTag('chill_main.provide_role'); + $container->registerForAutoconfiguration(NotificationFlagProviderInterface::class) + ->addTag('chill_main.notification_flag_provider'); $container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationController.php b/src/Bundle/ChillMainBundle/Controller/NotificationController.php index 1580b1fa0..6113bd4a9 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationController.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment; use Chill\MainBundle\Form\NotificationCommentType; use Chill\MainBundle\Form\NotificationType; use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; +use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider; use Chill\MainBundle\Notification\NotificationHandlerManager; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\NotificationRepository; @@ -57,7 +58,8 @@ class NotificationController extends AbstractController $notification ->setRelatedEntityClass($request->query->get('entityClass')) ->setRelatedEntityId($request->query->getInt('entityId')) - ->setSender($this->security->getUser()); + ->setSender($this->security->getUser()) + ->setType(NotificationByUserFlagProvider::FLAG); $tos = $request->query->all('tos'); diff --git a/src/Bundle/ChillMainBundle/Controller/UserProfileController.php b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php index a48d1a1e2..b022a2b60 100644 --- a/src/Bundle/ChillMainBundle/Controller/UserProfileController.php +++ b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php @@ -11,14 +11,11 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; -use Chill\MainBundle\Form\UserPhonenumberType; +use Chill\MainBundle\Form\UserProfileType; use Chill\MainBundle\Security\ChillSecurity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\Routing\Annotation\Route; @@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController } $user = $this->security->getUser(); - $editForm = $this->createPhonenumberEditForm($user); + $editForm = $this->createForm(UserProfileType::class, $user); + + $editForm->get('notificationFlags')->setData($user->getNotificationFlags()); + $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { - $phonenumber = $editForm->get('phonenumber')->getData(); + $notificationFlagsData = $editForm->get('notificationFlags')->getData(); + $user->setNotificationFlags($notificationFlagsData); - $user->setPhonenumber($phonenumber); - - $this->managerRegistry->getManager()->flush(); - $this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!')); + $em = $this->managerRegistry->getManager(); + $em->flush(); + $this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!')); return $this->redirectToRoute('chill_main_user_profile'); } @@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController 'form' => $editForm->createView(), ]); } - - private function createPhonenumberEditForm(UserInterface $user): FormInterface - { - return $this->createForm( - UserPhonenumberType::class, - $user, - ) - ->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]); - } } diff --git a/src/Bundle/ChillMainBundle/Entity/Notification.php b/src/Bundle/ChillMainBundle/Entity/Notification.php index 9f08e0487..20773b884 100644 --- a/src/Bundle/ChillMainBundle/Entity/Notification.php +++ b/src/Bundle/ChillMainBundle/Entity/Notification.php @@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -21,10 +22,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Entity] #[ORM\HasLifecycleCallbacks] #[ORM\Table(name: 'chill_main_notification')] -#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])] +#[ORM\Index(columns: ['relatedentityclass', 'relatedentityid'], name: 'chill_main_notification_related_entity_idx')] class Notification implements TrackUpdateInterface { - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false)] + #[ORM\Column(type: Types::TEXT, nullable: false)] private string $accessKey; private array $addedAddresses = []; @@ -36,12 +37,19 @@ class Notification implements TrackUpdateInterface #[ORM\JoinTable(name: 'chill_main_notification_addresses_user')] private Collection $addressees; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: UserGroup::class)] + #[ORM\JoinTable(name: 'chill_main_notification_addressee_user_group')] + private Collection $addresseeUserGroups; + /** * a list of destinee which will receive notifications. * * @var array|string[] */ - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['default' => '[]', 'jsonb' => true])] + #[ORM\Column(type: Types::JSON, options: ['default' => '[]', 'jsonb' => true])] private array $addressesEmails = []; /** @@ -60,21 +68,21 @@ class Notification implements TrackUpdateInterface #[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])] private Collection $comments; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $date; #[ORM\Id] #[ORM\GeneratedValue] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] + #[ORM\Column(type: Types::INTEGER)] private ?int $id = null; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] + #[ORM\Column(type: Types::TEXT)] private string $message = ''; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] + #[ORM\Column(type: Types::STRING, length: 255)] private string $relatedEntityClass = ''; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] + #[ORM\Column(type: Types::INTEGER)] private int $relatedEntityId; private array $removedAddresses = []; @@ -84,7 +92,7 @@ class Notification implements TrackUpdateInterface private ?User $sender = null; #[Assert\NotBlank(message: 'notification.Title must be defined')] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] + #[ORM\Column(type: Types::TEXT, options: ['default' => ''])] private string $title = ''; /** @@ -94,31 +102,46 @@ class Notification implements TrackUpdateInterface #[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')] private Collection $unreadBy; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private ?\DateTimeImmutable $updatedAt = null; #[ORM\ManyToOne(targetEntity: User::class)] private ?User $updatedBy = null; + #[ORM\Column(name: 'type', type: Types::STRING, nullable: true)] + private string $type = ''; + public function __construct() { $this->addressees = new ArrayCollection(); + $this->addresseeUserGroups = new ArrayCollection(); $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 + public function addAddressee(User|UserGroup $addressee): self { - if (!$this->addressees->contains($addressee)) { - $this->addressees[] = $addressee; - $this->addedAddresses[] = $addressee; + if ($addressee instanceof User) { + if (!$this->addressees->contains($addressee)) { + $this->addressees->add($addressee); + $this->addedAddresses[] = $addressee; + } + + return $this; + } + + if (!$this->addresseeUserGroups->contains($addressee)) { + $this->addresseeUserGroups->add($addressee); } return $this; } + /** + * @deprecated + */ public function addAddressesEmail(string $email) { if (!\in_array($email, $this->addressesEmails, true)) { @@ -152,13 +175,23 @@ class Notification implements TrackUpdateInterface #[Assert\Callback] public function assertCountAddresses(ExecutionContextInterface $context, $payload): void { - if (0 === (\count($this->getAddressesEmails()) + \count($this->getAddressees()))) { + if (0 === (\count($this->getAddresseeUserGroups()) + \count($this->getAddressees()))) { $context->buildViolation('notification.At least one addressee') ->atPath('addressees') ->addViolation(); } } + public function getAddresseeUserGroups(): Collection + { + return $this->addresseeUserGroups; + } + + public function setAddresseeUserGroups(Collection $addresseeUserGroups): void + { + $this->addresseeUserGroups = $addresseeUserGroups; + } + public function getAccessKey(): string { return $this->accessKey; @@ -182,6 +215,23 @@ class Notification implements TrackUpdateInterface return $this->addressees; } + public function getAllAddressees(): array + { + $allUsers = []; + + foreach ($this->getAddressees() as $user) { + $allUsers[$user->getId()] = $user; + } + + foreach ($this->getAddresseeUserGroups() as $userGroup) { + foreach ($userGroup->getUsers() as $user) { + $allUsers[$user->getId()] = $user; + } + } + + return array_values($allUsers); + } + /** * @return array|string[] */ @@ -303,12 +353,18 @@ class Notification implements TrackUpdateInterface $this->addressesOnLoad = null; } - public function removeAddressee(User $addressee): self + public function removeAddressee(User|UserGroup $addressee): self { - if ($this->addressees->removeElement($addressee)) { - $this->removedAddresses[] = $addressee; + if ($addressee instanceof User) { + if ($this->addressees->contains($addressee)) { + $this->addressees->removeElement($addressee); + + return $this; + } } + $this->addresseeUserGroups->removeElement($addressee); + return $this; } @@ -378,7 +434,7 @@ class Notification implements TrackUpdateInterface public function setUpdatedAt(\DateTimeInterface $datetime): self { - $this->updatedAt = $datetime; + $this->updatedAt = \DateTimeImmutable::createFromInterface($datetime); return $this; } @@ -389,4 +445,16 @@ class Notification implements TrackUpdateInterface return $this; } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function getType(): string + { + return $this->type; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 8a31779f9..61263ef85 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; #[ORM\Table(name: 'users')] class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface { + public const NOTIF_FLAG_IMMEDIATE_EMAIL = 'immediate-email'; + public const NOTIF_FLAG_DAILY_DIGEST = 'daily-digest'; + #[ORM\Id] #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\GeneratedValue(strategy: 'AUTO')] @@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter #[PhonenumberConstraint] private ?PhoneNumber $phonenumber = null; + /** + * @var array> + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] + private array $notificationFlags = []; + /** * User constructor. */ @@ -613,4 +622,57 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter return $this; } + + /** + * Check if the current object is an instance of User. + * + * @return bool returns true if the current object is an instance of User, false otherwise + */ + public function isUser(): bool + { + return true; + } + + public function getNotificationFlags(): array + { + return $this->notificationFlags; + } + + public function setNotificationFlags(array $notificationFlags) + { + $this->notificationFlags = $notificationFlags; + } + + public function getNotificationFlagData(string $flag): array + { + return $this->notificationFlags[$flag] ?? []; + } + + public function setNotificationFlagData(string $flag, array $data): void + { + $this->notificationFlags[$flag] = $data; + } + + public function isNotificationSendImmediately(string $type): bool + { + if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) { + return true; + } + + return false; + } + + public function isNotificationDailyDigest(string $type): bool + { + if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) { + return true; + } + + return false; + } + + public function getLocale(): string + { + return 'fr'; + } } diff --git a/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php b/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php new file mode 100644 index 000000000..d904ed5b5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php @@ -0,0 +1,75 @@ +notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + + if (isset($formsArray[$flag])) { + $flagForm = $formsArray[$flag]; + + $immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true) + || !array_key_exists($flag, $viewData); + $dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true); + + if ($flagForm->has('immediate_email')) { + $flagForm->get('immediate_email')->setData($immediateEmailChecked); + } + if ($flagForm->has('daily_email')) { + $flagForm->get('daily_email')->setData($dailyEmailChecked); + } + } + } + } + + public function mapFormsToData($forms, &$viewData): void + { + $formsArray = iterator_to_array($forms); + $viewData = []; + + foreach ($this->notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + + if (isset($formsArray[$flag])) { + $flagForm = $formsArray[$flag]; + $viewData[$flag] = []; + + if (true === $flagForm['immediate_email']->getData()) { + $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; + } + + if (true === $flagForm['daily_email']->getData()) { + $viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST; + } + + if ([] === $viewData[$flag]) { + $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; + } + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Form/NotificationType.php b/src/Bundle/ChillMainBundle/Form/NotificationType.php index 2bd8ba820..58fb1925d 100644 --- a/src/Bundle/ChillMainBundle/Form/NotificationType.php +++ b/src/Bundle/ChillMainBundle/Form/NotificationType.php @@ -12,17 +12,12 @@ 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 Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType; 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 { @@ -33,29 +28,14 @@ class NotificationType extends AbstractType 'label' => 'Title', 'required' => true, ]) - ->add('addressees', PickUserDynamicType::class, [ + ->add('addressees', PickUserGroupOrUserDynamicType::class, [ 'multiple' => true, - 'required' => false, + 'label' => 'notification.Pick user or user group', + 'empty_data' => '[]', + 'required' => true, ]) ->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(), - ], - 'label' => 'Email', - ], ]); } diff --git a/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php b/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php new file mode 100644 index 000000000..4535a4815 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php @@ -0,0 +1,63 @@ +notificationFlagProviders = $notificationFlagManager->getAllNotificationFlagProviders(); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders)); + + foreach ($this->notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + $builder->add($flag, FormType::class, [ + 'label' => $flagProvider->getLabel(), + 'required' => false, + ]); + + $builder->get($flag) + ->add('immediate_email', CheckboxType::class, [ + 'label' => false, + 'required' => false, + 'mapped' => false, + ]) + ->add('daily_email', CheckboxType::class, [ + 'label' => false, + 'required' => false, + 'mapped' => false, + ]) + ; + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/UserProfileType.php b/src/Bundle/ChillMainBundle/Form/UserProfileType.php new file mode 100644 index 000000000..f9fa65991 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/UserProfileType.php @@ -0,0 +1,41 @@ +add('phonenumber', ChillPhoneNumberType::class, [ + 'required' => false, + ]) + ->add('notificationFlags', NotificationFlagsType::class, [ + 'label' => false, + 'mapped' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => \Chill\MainBundle\Entity\User::class, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php b/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php new file mode 100644 index 000000000..5ed6696f7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php @@ -0,0 +1,102 @@ +clock->now(); + + if (null !== $cronJobExecution && $now->sub(new \DateInterval('PT23H45M')) < $cronJobExecution->getLastStart()) { + return false; + } + + // Run between 6 and 9 AM + return in_array((int) $now->format('H'), [6, 7, 8], true); + } + + public function getKey(): string + { + return 'daily-notification-digest'; + } + + /** + * @throws \DateInvalidOperationException + * @throws Exception + */ + public function run(array $lastExecutionData): ?array + { + $now = $this->clock->now(); + if (isset($lastExecutionData['last_execution'])) { + $lastExecution = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::ATOM, + $lastExecutionData['last_execution'] + ); + } else { + $lastExecution = $now->sub(new \DateInterval('P1D')); + } + + // Get distinct users who received notifications since the last execution + $sql = <<<'SQL' + SELECT DISTINCT cmnau.user_id + FROM chill_main_notification cmn + JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id + WHERE cmn.date >= :lastExecution AND cmn.date <= :now + SQL; + + $sqlStatement = $this->connection->prepare($sql); + $sqlStatement->bindValue('lastExecution', $lastExecution->format(\DateTimeInterface::RFC3339)); + $sqlStatement->bindValue('now', $now->format(\DateTimeInterface::RFC3339)); + $result = $sqlStatement->executeQuery(); + + $count = 0; + foreach ($result->fetchAllAssociative() as $row) { + $userId = (int) $row['user_id']; + + $message = new ScheduleDailyNotificationDigestMessage( + $userId, + $lastExecution, + $now + ); + + $this->messageBus->dispatch($message); + ++$count; + } + + $this->logger->info('[DailyNotificationDigestCronjob] Dispatched daily digest messages', [ + 'user_count' => $count, + 'last_execution' => $lastExecution->format('Y-m-d-H:i:s.u e'), + 'current_time' => $now->format('Y-m-d-H:i:s.u e'), + ]); + + return [ + 'last_execution' => $now->format('Y-m-d-H:i:s.u e'), + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php new file mode 100644 index 000000000..0a6aef393 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php @@ -0,0 +1,75 @@ +getUserId(); + $lastExecutionDate = $message->getLastExecutionDateTime(); + $currentDate = $message->getCurrentDateTime(); + + $user = $this->userRepository->find($userId); + if (null === $user) { + $this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [ + 'user_id' => $userId, + ]); + + throw new \InvalidArgumentException(sprintf('User with ID %s not found', $userId)); + } + + // Get all notifications for this user between last execution and current date + $notifications = $this->notificationRepository->findNotificationsForUserBetweenDates( + $userId, + $lastExecutionDate, + $currentDate + ); + + // Filter out notifications that should be sent in a daily digest + $dailyNotifications = array_filter($notifications, fn ($notification) => $user->isNotificationDailyDigest($notification->getType())); + + if ([] === $dailyNotifications) { + $this->logger->info('[ScheduleDailyNotificationDigestHandler] No daily notifications found for user', [ + 'user_id' => $userId, + ]); + + return; + } + + $this->notificationMailer->sendDailyDigest($user, $dailyNotifications); + + $this->logger->info('[ScheduleDailyNotificationDigestHandler] Sent daily digest', [ + 'user_id' => $userId, + 'notification_count' => count($dailyNotifications), + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php new file mode 100644 index 000000000..b27f16423 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php @@ -0,0 +1,68 @@ +notificationRepository->find($message->getNotificationId()); + $addressee = $this->userRepository->find($message->getAddresseeId()); + + if (null === $notification) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Notification not found', [ + 'notification_id' => $message->getNotificationId(), + ]); + + throw new \InvalidArgumentException(sprintf('Notification with ID %s not found', $message->getNotificationId())); + } + + if (null === $addressee) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [ + 'addressee_id' => $message->getAddresseeId(), + ]); + + throw new \InvalidArgumentException(sprintf('User with ID %s not found', $message->getAddresseeId())); + } + + try { + $this->notificationMailer->sendEmailToAddressee($notification, $addressee); + } catch (\Exception $e) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Failed to send email', [ + 'notification_id' => $message->getNotificationId(), + 'addressee_id' => $message->getAddresseeId(), + 'stacktrace' => $e->getTraceAsString(), + ]); + throw $e; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php new file mode 100644 index 000000000..335185503 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php @@ -0,0 +1,36 @@ +userId; + } + + public function getLastExecutionDateTime(): \DateTimeInterface + { + return $this->lastExecutionDate; + } + + public function getCurrentDateTime(): \DateTimeInterface + { + return $this->currentDate; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php new file mode 100644 index 000000000..fb9908b21 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php @@ -0,0 +1,30 @@ +notificationId; + } + + public function getAddresseeId(): int + { + return $this->addresseeId; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php index 7b535f1a7..2f888ffd5 100644 --- a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php @@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\NotificationComment; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage; use Doctrine\ORM\Event\PostPersistEventArgs; -use Doctrine\ORM\Event\PostUpdateEventArgs; use Psr\Log\LoggerInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; -class NotificationMailer +readonly class NotificationMailer { - public function __construct(private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) {} + public function __construct( + private MailerInterface $mailer, + private LoggerInterface $logger, + private MessageBusInterface $messageBus, + private TranslatorInterface $translator, + ) {} public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void { - $dests = [$comment->getNotification()->getSender(), ...$comment->getNotification()->getAddressees()->toArray()]; + $dests = [ + $comment->getNotification()->getSender(), + ...$comment->getNotification()->getAddressees()->toArray(), + ]; $uniqueDests = []; foreach ($dests as $dest) { @@ -69,55 +79,147 @@ class NotificationMailer */ public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void { - $this->sendNotificationEmailsToAddresses($notification); + $this->sendNotificationEmailsToAddressees($notification); $this->sendNotificationEmailsToAddressesEmails($notification); } - public function postUpdateNotification(Notification $notification, PostUpdateEventArgs $eventArgs): void + private function sendNotificationEmailsToAddressees(Notification $notification): void { - $this->sendNotificationEmailsToAddressesEmails($notification); - } + if ('' === $notification->getType()) { + $this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [ + 'notification_id' => $notification->getId(), + ]); - private function sendNotificationEmailsToAddresses(Notification $notification): void - { - foreach ($notification->getAddressees() as $addressee) { + return; + } + + foreach ($notification->getAllAddressees() as $addressee) { if (null === $addressee->getEmail()) { continue; } - if ($notification->isSystem()) { - $email = new Email(); - $email - ->text($notification->getMessage()); - } else { - $email = new TemplatedEmail(); - $email - ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') - ->context([ - 'notification' => $notification, - 'dest' => $addressee, - ]); - } + $this->processNotificationForAddressee($notification, $addressee); + } + } + private function processNotificationForAddressee(Notification $notification, User $addressee): void + { + $notificationType = $notification->getType(); + + if ($addressee->isNotificationSendImmediately($notificationType)) { + $this->scheduleImmediateEmail($notification, $addressee); + } + } + + private function scheduleImmediateEmail(Notification $notification, User $addressee): void + { + $message = new SendImmediateNotificationEmailMessage( + $notification->getId(), + $addressee->getId() + ); + + $this->messageBus->dispatch($message); + + $this->logger->info('[NotificationMailer] Scheduled immediate email', [ + 'notification_id' => $notification->getId(), + 'addressee_email' => $addressee->getEmail(), + ]); + } + + /** + * This method sends the email but is now called by the immediate notification email message handler. + * + * @throws TransportExceptionInterface + */ + public function sendEmailToAddressee(Notification $notification, User $addressee): void + { + if (null === $addressee->getEmail()) { + return; + } + + if ($notification->isSystem()) { + $email = new Email(); + $email->text($notification->getMessage()); + } else { + $email = new TemplatedEmail(); $email - ->subject($notification->getTitle()) - ->to($addressee->getEmail()); - - try { - $this->mailer->send($email); - } catch (TransportExceptionInterface $e) { - $this->logger->warning('[NotificationMailer] could not send an email notification', [ - 'to' => $addressee->getEmail(), - 'error_message' => $e->getMessage(), - 'error_trace' => $e->getTraceAsString(), + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') + ->context([ + 'notification' => $notification, + 'dest' => $addressee, ]); - } + } + + $email + ->subject($notification->getTitle()) + ->to($addressee->getEmail()); + + try { + $this->mailer->send($email); + $this->logger->info('[NotificationMailer] Email sent successfully', [ + 'notification_id' => $notification->getId(), + 'addressee_email' => $addressee->getEmail(), + ]); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] Could not send an email notification', [ + 'to' => $addressee->getEmail(), + 'notification_id' => $notification->getId(), + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Send daily digest email with multiple notifications to a user. + * + * @throws TransportExceptionInterface + */ + public function sendDailyDigest(User $user, array $notifications): void + { + if (null === $user->getEmail() || [] === $notifications) { + return; + } + + $email = new TemplatedEmail(); + $email + ->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig') + ->context([ + 'user' => $user, + 'notifications' => $notifications, + 'notification_count' => count($notifications), + ]) + ->subject($this->translator->trans('notification.Daily Notification Digest')) + ->to($user->getEmail()); + + try { + $this->mailer->send($email); + $this->logger->info('[NotificationMailer] Daily digest email sent successfully', [ + 'user_email' => $user->getEmail(), + 'notification_count' => count($notifications), + ]); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] Could not send daily digest email', [ + 'to' => $user->getEmail(), + 'notification_count' => count($notifications), + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + throw $e; } } private function sendNotificationEmailsToAddressesEmails(Notification $notification): void { - foreach ($notification->getAddressesEmailsAdded() as $emailAddress) { + foreach ($notification->getAddresseeUserGroups() as $userGroup) { + + if (!$userGroup->hasEmail()) { + continue; + } + + $emailAddress = $userGroup->getEmail(); + $email = new TemplatedEmail(); $email ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') diff --git a/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php b/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php new file mode 100644 index 000000000..887d7f3d1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php @@ -0,0 +1,30 @@ + + */ + private array $notificationFlagProviders; + + public function __construct( + iterable $notificationFlagProviders, + ) { + $this->notificationFlagProviders = iterator_to_array($notificationFlagProviders); + } + + public function getAllNotificationFlagProviders(): array + { + return $this->notificationFlagProviders; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php index fb79a7397..99fb57094 100644 --- a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php @@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository return $qb; } - private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder + private function queryByAddressee(User $addressee): QueryBuilder { $qb = $this->repository->createQueryBuilder('n'); $qb - ->where($qb->expr()->isMemberOf(':addressee', 'n.addressees')) + ->leftJoin('n.addresseeUserGroups', 'aug') + ->leftJoin('aug.users', 'ugu') + ->where( + $qb->expr()->orX( + $qb->expr()->isMemberOf(':addressee', 'n.addressees'), + $qb->expr()->eq('ugu.id', ':addressee') + ) + ) ->setParameter('addressee', $addressee); return $qb; @@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository return $nq->getResult(); } + + /** + * Find all notifications for a user that were created between two dates. + * + * @return array|Notification[] + */ + public function findNotificationsForUserBetweenDates(int $userId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array + { + $rsm = new Query\ResultSetMappingBuilder($this->em); + $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); + + $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. + 'FROM chill_main_notification cmn '. + 'JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id '. + 'WHERE cmnau.user_id = :userId '. + 'AND cmn.date >= :startDate '. + 'AND cmn.date <= :endDate '. + 'ORDER BY cmn.date DESC'; + + $nq = $this->em->createNativeQuery($sql, $rsm) + ->setParameter('userId', $userId) + ->setParameter('startDate', $startDate, Types::DATETIME_MUTABLE) + ->setParameter('endDate', $endDate, Types::DATETIME_MUTABLE); + + return $nq->getResult(); + } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig index 1afdbb2c9..fa773d56e 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig @@ -18,8 +18,9 @@ {%- endif -%} {%- endblock form_label %} +{# this has been rewritten for chill #} {% block form_label_class -%} - col-sm-4 + {% if 'div_col_width' in label_attr|default({})|keys %}{% if label_attr['div_col_width'] is not same as false %}{{ label_attr['div_col_width'] }}{% endif %}{% else %}col-sm-4{% endif %} {%- endblock form_label_class %} {# Rows #} 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 f9516f92f..0e43b73e6 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig @@ -69,41 +69,44 @@ {% endif %} {% endif %} - {% if c.notification.addressees|length > 0 %} + {% if c.notification.addressees|length > 0 or c.notification.addresseeUserGroups|length > 0 %}
  • {% if c.notification_cc is defined %} {% if c.notification_cc %} - - {{ "notification.cc" | trans }} : - - + + {{ "notification.cc" | trans }} : + + {% else %} - - {{ "notification.to" | trans }} : - - + + {{ "notification.to" | trans }} : + + {% endif %} {% else %} - - {{ "notification.to" | trans }} : - - + + {{ "notification.to" | trans }} : + + {% endif %} {% for a in c.notification.addressees %} - {{ a | chill_entity_render_string({'at_date': c.notification.date}) }} - + {{ a | chill_entity_render_string({'at_date': c.notification.date}) }} + {% endfor %} {% for a in c.notification.addressesEmails %} - {{ a }} - + {{ a }} + + {% endfor %} + {% for ug in c.notification.addresseeUserGroups %} + {{ ug|chill_entity_render_box }} {% 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 4dfd340b6..8797c276a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig @@ -21,8 +21,6 @@ {{ 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_daily_digest.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig new file mode 100644 index 000000000..084b0d307 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig @@ -0,0 +1,24 @@ +{% apply markdown_to_html %} +# {{ 'notification.daily_digest.title'|trans }} + +{{ 'notification.daily_digest.greeting'|trans({'%user%': user.label ?? user.email}) }}, + +{{ 'daily_notifications'|trans({'notification_count': notification_count}) }} + +{% for notification in notifications %} +## {{ notification.title }} + +{{ notification.message }} + +{{ 'notification.daily_digest.view_notification'|trans }} + +{{ absolute_url(path('chill_main_notification_show', {'_locale': user.locale, 'id': notification.id }, false)) }} + +{% if not loop.last %} +--- +{% endif %} +{% endfor %} + +--- +{{ 'notification.daily_digest.signature'|trans }} +{% endapply %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig index 360d748a5..d25f6645f 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig @@ -20,7 +20,7 @@ {% extends "@ChillMain/layout.html.twig" %} -{% block title %}{{"My profile"|trans}}{% endblock %} +{% block title %}{{"user.profile.title"|trans}}{% endblock %} {% block content %}
    @@ -45,9 +45,35 @@ {{ form_start(form) }} {{ form_row(form.phonenumber) }} +

    {{ 'user.profile.notification_preferences'|trans }}

    + + + + + + + + + + {% for flag in form.notificationFlags %} + + + + + + {% endfor %} + +
    {{ 'notification.flags.type'|trans }}{{ 'notification.flags.preferences.immediate_email'|trans }}{{ 'notification.flags.preferences.daily_email'|trans }}
    + {{ form_label(flag, null, {'label_attr': {'div_col_width': false}}) }} + + {{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} + + {{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} +
    +
    • - {{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }} +
    diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php index 2e945dcb4..e9e7ff760 100644 --- a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Entity; +namespace Chill\MainBundle\Tests\Entity; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; @@ -49,8 +49,8 @@ final class NotificationTest extends KernelTestCase $notification = new Notification(); $notification->addAddressee($user1 = new User()); $notification->addAddressee($user2 = new User()); - $notification->getAddressees()->add($user3 = new User()); - $notification->getAddressees()->add($user4 = new User()); + $notification->addAddressee($user3 = new User()); + $notification->addAddressee($user4 = new User()); $this->assertCount(4, $notification->getAddressees()); @@ -85,6 +85,30 @@ final class NotificationTest extends KernelTestCase $this->assertNotContains('other', $notification->getAddressesEmailsAdded()); } + public function testIsSendImmediately(): void + { + $notification = new Notification(); + $notification->setType('test_notification_type'); + + $user = new User(); + + // no notification flags + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email'); + + // immediate-email preference + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]); + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email'); + + // daily-email preference + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]); + $this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only'); + $this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email'); + + // a different notification type + $notification->setType('other_notification_type'); + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return false when notification type does not match any preference'); + } + /** * @dataProvider generateNotificationData */ diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php new file mode 100644 index 000000000..0caeebc36 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php @@ -0,0 +1,46 @@ +dailyNotificationDigestCronjob = self::getContainer()->get(DailyNotificationDigestCronjob::class); + } + + public function testRunWithNullPreviousExecutionData(): void + { + $actual = $this->dailyNotificationDigestCronjob->run([]); + + self::assertArrayHasKey('last_execution', $actual); + self::assertInstanceOf( + \DateTimeImmutable::class, + \DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']), + 'test that the string can be converted to a date' + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php new file mode 100644 index 000000000..5894385c6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php @@ -0,0 +1,81 @@ +clock = $this->createMock(ClockInterface::class); + $this->connection = $this->createMock(Connection::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->cronjob = new DailyNotificationDigestCronjob( + $this->clock, + $this->connection, + $this->messageBus, + $this->logger + ); + } + + public function testGetKey(): void + { + $this->assertEquals('daily-notification-digest', $this->cronjob->getKey()); + } + + /** + * @dataProvider canRunTimeDataProvider + */ + public function testCanRunWithNullCronJobExecution(int $hour, bool $expected): void + { + $now = new \DateTimeImmutable("2024-01-01 {$hour}:00:00"); + $this->clock->expects($this->once()) + ->method('now') + ->willReturn($now); + + $result = $this->cronjob->canRun(null); + + $this->assertEquals($expected, $result); + } + + public static function canRunTimeDataProvider(): array + { + return [ + 'hour 5 - should not run' => [5, false], + 'hour 6 - should run' => [6, true], + 'hour 7 - should run' => [7, true], + 'hour 8 - should run' => [8, true], + 'hour 9 - should not run' => [9, false], + 'hour 10 - should not run' => [10, false], + 'hour 23 - should not run' => [23, false], + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php index fad4a89f5..03be565a2 100644 --- a/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php @@ -17,13 +17,18 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Notification\Email\NotificationMailer; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PostPersistEventArgs; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; +use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage; /** * @internal @@ -112,13 +117,149 @@ class NotificationMailerTest extends TestCase $mailer->postPersistComment($comment, new PostPersistEventArgs($comment, $objectManager->reveal())); } + /** + * @throws \ReflectionException + * @throws Exception + */ + public function testProcessNotificationForAddresseeWithImmediateEmailPreference(): void + { + // Create a real notification entity + $notification = new Notification(); + $notification->setType('test_notification_type'); + + // Use reflection to set the ID since it's normally generated by the database + $reflectionNotification = new \ReflectionClass(Notification::class); + $idProperty = $reflectionNotification->getProperty('id'); + $idProperty->setAccessible(true); + $idProperty->setValue($notification, 123); + + // Create a real user entity + $user = new User(); + $user->setEmail('user@example.com'); + + // Use reflection to set the ID since it's normally generated by the database + $reflectionUser = new \ReflectionClass(User::class); + $idProperty = $reflectionUser->getProperty('id'); + $idProperty->setAccessible(true); + $idProperty->setValue($user, 456); + + // Set notification flags for the user + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(fn (SendImmediateNotificationEmailMessage $message) => 123 === $message->getNotificationId() + && 456 === $message->getAddresseeId())) + ->willReturn(new Envelope(new \stdClass())); + + $mailer = $this->buildNotificationMailer(null, $messageBus); + + // Call the method that processes notifications + $reflection = new \ReflectionClass(NotificationMailer::class); + $method = $reflection->getMethod('processNotificationForAddressee'); + $method->setAccessible(true); + $method->invoke($mailer, $notification, $user); + } + + public function testSendDailyDigest(): void + { + // Create a user + $user = new User(); + $user->setEmail('user@example.com'); + + // Create some notifications + $notification = $this->prophesize(Notification::class); + $notification->getTitle()->willReturn('Notification 1'); + $notification->getMessage()->willReturn('Message 1'); + $notification->getId()->willReturn(123); + + $notification2 = $this->prophesize(Notification::class); + $notification2->getTitle()->willReturn('Notification 2'); + $notification2->getMessage()->willReturn('Message 2'); + $notification2->getId()->willReturn(456); + + $notifications = [$notification, $notification2]; + + // Mock the mailer to verify that an email is sent with the correct parameters + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::that(function ($email) use ($user) { + // Verify that the email is sent to the correct user + foreach ($email->getTo() as $address) { + if ($user->getEmail() === $address->getAddress()) { + return true; + } + } + + return false; + }))->shouldBeCalledOnce(); + + // Create a translator that returns a fixed string for the subject + $translator = $this->prophesize(TranslatorInterface::class); + $translator->trans('notification.Daily Notification Digest')->willReturn('Daily Digest'); + + // Create the notification mailer with the mocked mailer and translator + $notificationMailer = $this->buildNotificationMailer($mailer->reveal(), null, $translator->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + + public function testSendDailyDigestWithNoNotifications(): void + { + // Create a user + $user = new User(); + $user->setEmail('user@example.com'); + + // Empty notifications array + $notifications = []; + + // Mock the mailer to verify that no email is sent + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::any())->shouldNotBeCalled(); + + // Create the notification mailer with the mocked mailer + $notificationMailer = $this->buildNotificationMailer($mailer->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + + public function testSendDailyDigestWithUserHavingNoEmail(): void + { + // Create a user with no email + $user = new User(); + $user->setEmail(null); + + // Create some notifications + $notification = $this->prophesize(Notification::class); + $notification->getTitle()->willReturn('Notification 1'); + $notification->getMessage()->willReturn('Message 1'); + $notification->getId()->willReturn(123); + + $notifications = [$notification]; + + // Mock the mailer to verify that no email is sent + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::any())->shouldNotBeCalled(); + + // Create the notification mailer with the mocked mailer + $notificationMailer = $this->buildNotificationMailer($mailer->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + private function buildNotificationMailer( ?MailerInterface $mailer = null, + ?MessageBusInterface $messageBus = null, + ?TranslatorInterface $translator = null, ): NotificationMailer { return new NotificationMailer( - $mailer, + $mailer ?? $this->prophesize(MailerInterface::class)->reveal(), new NullLogger(), - new Translator('fr') + $messageBus ?? $this->prophesize(MessageBusInterface::class)->reveal(), + $translator ?? new Translator('fr') ); } } diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php index 17f5194cc..3775869e7 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Notification\FlagProviders\WorkflowTransitionNotificationFlagProvider; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Doctrine\ORM\EntityManagerInterface; @@ -125,7 +126,8 @@ class NotificationOnTransition implements EventSubscriberInterface ->setRelatedEntityClass(EntityWorkflow::class) ->setTitle($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context)) ->setMessage($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig', $context)) - ->addAddressee($subscriber); + ->addAddressee($subscriber) + ->setType(WorkflowTransitionNotificationFlagProvider::FLAG); $this->entityManager->persist($notification); } } diff --git a/src/Bundle/ChillMainBundle/config/services/form.yaml b/src/Bundle/ChillMainBundle/config/services/form.yaml index f31829915..e917b37c9 100644 --- a/src/Bundle/ChillMainBundle/config/services/form.yaml +++ b/src/Bundle/ChillMainBundle/config/services/form.yaml @@ -139,6 +139,11 @@ services: autowire: true autoconfigure: true + Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper: + autowire: true + autoconfigure: true + + Chill\MainBundle\Form\UserProfileType: ~ Chill\MainBundle\Form\AbsenceType: ~ Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~ Chill\MainBundle\Form\RegroupmentType: ~ diff --git a/src/Bundle/ChillMainBundle/config/services/notification.yaml b/src/Bundle/ChillMainBundle/config/services/notification.yaml index be3252003..ff3087ddf 100644 --- a/src/Bundle/ChillMainBundle/config/services/notification.yaml +++ b/src/Bundle/ChillMainBundle/config/services/notification.yaml @@ -12,6 +12,10 @@ services: arguments: $routeParameters: '%chill_main.notifications%' + Chill\MainBundle\Notification\NotificationFlagManager: + arguments: + $notificationFlagProviders: !tagged_iterator chill_main.notification_flag_provider + Chill\MainBundle\Notification\NotificationHandlerManager: arguments: $handlers: !tagged_iterator chill_main.notification_handler @@ -55,14 +59,6 @@ services: 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/Version20250610102953.php b/src/Bundle/ChillMainBundle/migrations/Version20250610102953.php new file mode 100644 index 000000000..6d6d2c2ab --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250610102953.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + ALTER TABLE users ADD notificationFlags JSONB DEFAULT '[]' NOT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE users DROP notificationFlags + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php b/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php new file mode 100644 index 000000000..c3a09062f --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + ALTER TABLE chill_main_notification ADD type VARCHAR(255) NOT NULL DEFAULT 'default_notification_type' + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification DROP type + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php b/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php new file mode 100644 index 000000000..3cf715db5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE chill_main_notification_addressee_user_group (notification_id INT NOT NULL, usergroup_id INT NOT NULL, PRIMARY KEY(notification_id, usergroup_id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_ECF81C07EF1A9D84 ON chill_main_notification_addressee_user_group (notification_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_ECF81C07D2112630 ON chill_main_notification_addressee_user_group (usergroup_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07EF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07EF1A9D84 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07D2112630 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_main_notification_addressee_user_group + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 2982d94db..57a57dba1 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -49,6 +49,12 @@ notification: other {# commentaires} } +daily_notifications: >- + {notification_count, plural, + =1 {Voici votre notification du jour :} + other {Voici vos # notifications du jour :} + } + workflow: My workflows with counter: >- {wc, plural, diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index d3498cba9..055575516 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -52,9 +52,10 @@ user: current_user: Utilisateur courant profile: title: Mon profil - Phonenumber successfully updated!: Numéro de téléphone mis à jour! + Profile successfully updated!: Votre profil a été mis à jour! no job: Pas de métier assigné no scope: Pas de cercle assigné + notification_preferences: Préférences pour mes notifications user_group: inactive: Inactif @@ -674,6 +675,7 @@ Subscribe all steps: Recevoir une notification à chaque étape CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows notification: + Daily Notification Digest: Résumé des notifications quotidiennes Notification: Notification Notifications: Notifications My own notifications: Mes notifications @@ -712,13 +714,36 @@ notification: 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 + Pick user or user group: Selectionner un utilisateur / groupe d'utilisateurs mark_as_read: Marquer comme lu mark_as_unread: Marquer comme non-lu + flags: + type: Type de notification + by-user: Lorsqu'un utilisateur vous envoie une notification personnelle + referrer-acc-course: Lorsqu'un autre utilisateur vous désigne comme référent d'un parcours + person-address-move: Lorsqu'un autre utilisateur enregistre le déménagement d'un usager concerné par un parcours dont vous êtes le référent. + person: Notification sur un usager + workflow-trans: Lorsqu'un autre utilisateur applique une transition à un workflow. + none selected message: Si vous ne sélectionnez aucune option, vous ne recevrez pas d'email concernant ce type de notification. + preferences: + column_title: Préférences + immediate_email: Email immédiat + daily_email: Récapitulatif quotidien + no_email: Ne pas recevoir un email + + daily_digest: + title: "Résumé quotidien des notifications" + greeting: "Bonjour %user%" + intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)." + view_notification: "Vous pouvez visualiser la notification et y répondre ici:" + signature: "Le logiciel Chill" + CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés + export: role: export_role: Exports diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php index 64d2b8f12..8093a695c 100644 --- a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; +use Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; @@ -65,7 +66,8 @@ class PersonAddressMoveEventSubscriber implements EventSubscriberInterface ->setMessage($this->engine->render('@ChillPerson/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig', [ 'oldPersonLocation' => $person, 'period' => $period, - ])); + ])) + ->setType(PersonAddressMoveNotificationFlagProvider::FLAG); $this->notificationPersister->persist($notification); } diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php index de1eac37d..253de9fb9 100644 --- a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider; use Doctrine\Persistence\Event\LifecycleEventArgs; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Security; @@ -73,7 +74,8 @@ class UserRefEventSubscriber implements EventSubscriberInterface 'accompanyingCourse' => $period, ] )) - ->addAddressee($period->getUser()); + ->addAddressee($period->getUser()) + ->setType(DesignatedReferrerNotificationFlagProvider::FLAG); $this->notificationPersister->persist($notification); } diff --git a/src/Bundle/ChillPersonBundle/ChillPersonBundle.php b/src/Bundle/ChillPersonBundle/ChillPersonBundle.php index 89477a47b..e97ccecdf 100644 --- a/src/Bundle/ChillPersonBundle/ChillPersonBundle.php +++ b/src/Bundle/ChillPersonBundle/ChillPersonBundle.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\PersonBundle; +use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface; use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface; use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass; use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface; @@ -35,5 +36,7 @@ class ChillPersonBundle extends Bundle ->addTag('chill_person.person_move_handler'); $container->registerForAutoconfiguration(CustomizeListPersonHelperInterface::class) ->addTag('chill_person.list_person_customizer'); + $container->registerForAutoconfiguration(NotificationFlagProviderInterface::class) + ->addTag('chill_main.notification_flag_provider'); } } diff --git a/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php b/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php new file mode 100644 index 000000000..df92e58aa --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php @@ -0,0 +1,31 @@ +