Resolve "Notification: envoi à des groupes utilisateurs"

This commit is contained in:
LenaertsJ 2025-07-20 20:18:49 +00:00 committed by Julien Fastré
parent 5bdb2df929
commit ab8da4ab7a
47 changed files with 1635 additions and 148 deletions

View File

@ -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, `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. 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 ### 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. 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). 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 #### Running Tests
The tests are run from the project's root (not from the bundle's root).
```bash ```bash
# Run all tests # Run all tests
vendor/bin/phpunit vendor/bin/phpunit

View File

@ -62,8 +62,10 @@ framework:
'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority 'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority
'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async
'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage': async
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
# end of routes added by chill-bundles recipes # end of routes added by chill-bundles recipes
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async

View File

@ -24,7 +24,11 @@ use Doctrine\ORM\EntityManagerInterface;
class CalendarForShortMessageProvider 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. * Generate calendars instance.

View File

@ -21,7 +21,6 @@ namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider; use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface; use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -82,10 +81,16 @@ final class CalendarForShortMessageProviderTest extends TestCase
$em = $this->prophesize(EntityManagerInterface::class); $em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled(); $em->clear()->shouldBeCalled();
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
'startDate' => new \DateTimeImmutable('yesterday'),
'endDate' => new \DateTimeImmutable('now'),
]);
$provider = new CalendarForShortMessageProvider( $provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(), $calendarRepository->reveal(),
$em->reveal(), $em->reveal(),
new DefaultRangeGenerator() $calendarRangeGenerator->reveal(),
); );
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
@ -103,26 +108,32 @@ final class CalendarForShortMessageProviderTest extends TestCase
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type('int'), Argument::type('int'),
Argument::exact(0) 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( $calendarRepository->findByNotificationAvailable(
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type('int'), Argument::type('int'),
Argument::not(0) Argument::exact(10)
)->will(static fn ($args) => [])->shouldBeCalledTimes(1); )->will(static fn ($args) => [])->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class); $em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled(); $em->clear()->shouldBeCalled();
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
'startDate' => new \DateTimeImmutable('yesterday'),
'endDate' => new \DateTimeImmutable('now'),
]);
$provider = new CalendarForShortMessageProvider( $provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(), $calendarRepository->reveal(),
$em->reveal(), $em->reveal(),
new DefaultRangeGenerator() $calendarRangeGenerator->reveal(),
); );
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
$this->assertEquals(1, \count($calendars)); $this->assertEquals(10, \count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars); $this->assertContainsOnly(Calendar::class, $calendars);
} }
} }

View File

@ -20,6 +20,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompiler
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\MainBundle\Notification\NotificationHandlerInterface; use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchApiInterface; use Chill\MainBundle\Search\SearchApiInterface;
@ -61,6 +62,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.entity_info_provider'); ->addTag('chill_main.entity_info_provider');
$container->registerForAutoconfiguration(ProvideRoleInterface::class) $container->registerForAutoconfiguration(ProvideRoleInterface::class)
->addTag('chill_main.provide_role'); ->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 SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);

View File

@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Form\NotificationCommentType; use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Form\NotificationType; use Chill\MainBundle\Form\NotificationType;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider;
use Chill\MainBundle\Notification\NotificationHandlerManager; use Chill\MainBundle\Notification\NotificationHandlerManager;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository; use Chill\MainBundle\Repository\NotificationRepository;
@ -57,7 +58,8 @@ class NotificationController extends AbstractController
$notification $notification
->setRelatedEntityClass($request->query->get('entityClass')) ->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId')) ->setRelatedEntityId($request->query->getInt('entityId'))
->setSender($this->security->getUser()); ->setSender($this->security->getUser())
->setType(NotificationByUserFlagProvider::FLAG);
$tos = $request->query->all('tos'); $tos = $request->query->all('tos');

View File

@ -11,14 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Form\UserPhonenumberType; use Chill\MainBundle\Form\UserProfileType;
use Chill\MainBundle\Security\ChillSecurity; use Chill\MainBundle\Security\ChillSecurity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController
} }
$user = $this->security->getUser(); $user = $this->security->getUser();
$editForm = $this->createPhonenumberEditForm($user); $editForm = $this->createForm(UserProfileType::class, $user);
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$phonenumber = $editForm->get('phonenumber')->getData(); $notificationFlagsData = $editForm->get('notificationFlags')->getData();
$user->setNotificationFlags($notificationFlagsData);
$user->setPhonenumber($phonenumber); $em = $this->managerRegistry->getManager();
$em->flush();
$this->managerRegistry->getManager()->flush(); $this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
$this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile'); return $this->redirectToRoute('chill_main_user_profile');
} }
@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController
'form' => $editForm->createView(), 'form' => $editForm->createView(),
]); ]);
} }
private function createPhonenumberEditForm(UserInterface $user): FormInterface
{
return $this->createForm(
UserPhonenumberType::class,
$user,
)
->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
}
} }

View File

@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@ -21,10 +22,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'chill_main_notification')] #[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 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 string $accessKey;
private array $addedAddresses = []; private array $addedAddresses = [];
@ -36,12 +37,19 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_user')] #[ORM\JoinTable(name: 'chill_main_notification_addresses_user')]
private Collection $addressees; private Collection $addressees;
/**
* @var Collection<int, UserGroup>
*/
#[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. * a list of destinee which will receive notifications.
* *
* @var array|string[] * @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 = []; private array $addressesEmails = [];
/** /**
@ -60,21 +68,21 @@ class Notification implements TrackUpdateInterface
#[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])] #[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])]
private Collection $comments; private Collection $comments;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $date; private \DateTimeImmutable $date;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
private string $message = ''; private string $message = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
private string $relatedEntityClass = ''; private string $relatedEntityClass = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private int $relatedEntityId; private int $relatedEntityId;
private array $removedAddresses = []; private array $removedAddresses = [];
@ -84,7 +92,7 @@ class Notification implements TrackUpdateInterface
private ?User $sender = null; private ?User $sender = null;
#[Assert\NotBlank(message: 'notification.Title must be defined')] #[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 = ''; private string $title = '';
/** /**
@ -94,31 +102,46 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')] #[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')]
private Collection $unreadBy; private Collection $unreadBy;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $updatedAt = null; private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
private ?User $updatedBy = null; private ?User $updatedBy = null;
#[ORM\Column(name: 'type', type: Types::STRING, nullable: true)]
private string $type = '';
public function __construct() public function __construct()
{ {
$this->addressees = new ArrayCollection(); $this->addressees = new ArrayCollection();
$this->addresseeUserGroups = new ArrayCollection();
$this->unreadBy = new ArrayCollection(); $this->unreadBy = new ArrayCollection();
$this->comments = new ArrayCollection(); $this->comments = new ArrayCollection();
$this->setDate(new \DateTimeImmutable()); $this->setDate(new \DateTimeImmutable());
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(24)); $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)) { if ($addressee instanceof User) {
$this->addressees[] = $addressee; if (!$this->addressees->contains($addressee)) {
$this->addedAddresses[] = $addressee; $this->addressees->add($addressee);
$this->addedAddresses[] = $addressee;
}
return $this;
}
if (!$this->addresseeUserGroups->contains($addressee)) {
$this->addresseeUserGroups->add($addressee);
} }
return $this; return $this;
} }
/**
* @deprecated
*/
public function addAddressesEmail(string $email) public function addAddressesEmail(string $email)
{ {
if (!\in_array($email, $this->addressesEmails, true)) { if (!\in_array($email, $this->addressesEmails, true)) {
@ -152,13 +175,23 @@ class Notification implements TrackUpdateInterface
#[Assert\Callback] #[Assert\Callback]
public function assertCountAddresses(ExecutionContextInterface $context, $payload): void 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') $context->buildViolation('notification.At least one addressee')
->atPath('addressees') ->atPath('addressees')
->addViolation(); ->addViolation();
} }
} }
public function getAddresseeUserGroups(): Collection
{
return $this->addresseeUserGroups;
}
public function setAddresseeUserGroups(Collection $addresseeUserGroups): void
{
$this->addresseeUserGroups = $addresseeUserGroups;
}
public function getAccessKey(): string public function getAccessKey(): string
{ {
return $this->accessKey; return $this->accessKey;
@ -182,6 +215,23 @@ class Notification implements TrackUpdateInterface
return $this->addressees; 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[] * @return array|string[]
*/ */
@ -303,12 +353,18 @@ class Notification implements TrackUpdateInterface
$this->addressesOnLoad = null; $this->addressesOnLoad = null;
} }
public function removeAddressee(User $addressee): self public function removeAddressee(User|UserGroup $addressee): self
{ {
if ($this->addressees->removeElement($addressee)) { if ($addressee instanceof User) {
$this->removedAddresses[] = $addressee; if ($this->addressees->contains($addressee)) {
$this->addressees->removeElement($addressee);
return $this;
}
} }
$this->addresseeUserGroups->removeElement($addressee);
return $this; return $this;
} }
@ -378,7 +434,7 @@ class Notification implements TrackUpdateInterface
public function setUpdatedAt(\DateTimeInterface $datetime): self public function setUpdatedAt(\DateTimeInterface $datetime): self
{ {
$this->updatedAt = $datetime; $this->updatedAt = \DateTimeImmutable::createFromInterface($datetime);
return $this; return $this;
} }
@ -389,4 +445,16 @@ class Notification implements TrackUpdateInterface
return $this; return $this;
} }
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type;
}
} }

View File

@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
#[ORM\Table(name: 'users')] #[ORM\Table(name: 'users')]
class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface 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\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\GeneratedValue(strategy: 'AUTO')]
@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[PhonenumberConstraint] #[PhonenumberConstraint]
private ?PhoneNumber $phonenumber = null; private ?PhoneNumber $phonenumber = null;
/**
* @var array<string, list<string>>
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $notificationFlags = [];
/** /**
* User constructor. * User constructor.
*/ */
@ -613,4 +622,57 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this; 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';
}
} }

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\DataMapper;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Form\DataMapperInterface;
final readonly class NotificationFlagDataMapper implements DataMapperInterface
{
public function __construct(private array $notificationFlagProviders) {}
public function mapDataToForms($viewData, $forms): void
{
if (null === $viewData) {
$viewData = [];
}
$formsArray = iterator_to_array($forms);
foreach ($this->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;
}
}
}
}
}

View File

@ -12,17 +12,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form; namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillTextareaType; 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\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; 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 class NotificationType extends AbstractType
{ {
@ -33,29 +28,14 @@ class NotificationType extends AbstractType
'label' => 'Title', 'label' => 'Title',
'required' => true, 'required' => true,
]) ])
->add('addressees', PickUserDynamicType::class, [ ->add('addressees', PickUserGroupOrUserDynamicType::class, [
'multiple' => true, 'multiple' => true,
'required' => false, 'label' => 'notification.Pick user or user group',
'empty_data' => '[]',
'required' => true,
]) ])
->add('message', ChillTextareaType::class, [ ->add('message', ChillTextareaType::class, [
'required' => false, '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',
],
]); ]);
} }

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NotificationFlagsType extends AbstractType
{
private readonly array $notificationFlagProviders;
public function __construct(NotificationFlagManager $notificationFlagManager)
{
$this->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,
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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,
]);
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class DailyNotificationDigestCronjob implements CronJobInterface
{
public function __construct(
private ClockInterface $clock,
private Connection $connection,
private MessageBusInterface $messageBus,
private LoggerInterface $logger,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
$now = $this->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'),
];
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ScheduleDailyNotificationDigestHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
*/
public function __invoke(ScheduleDailyNotificationDigestMessage $message): void
{
$userId = $message->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),
]);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SendImmediateNotificationEmailHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
* @throws \Exception
*/
public function __invoke(SendImmediateNotificationEmailMessage $message): void
{
$notification = $this->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;
}
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages;
readonly class ScheduleDailyNotificationDigestMessage
{
public function __construct(
private int $userId,
private \DateTimeInterface $lastExecutionDate,
private \DateTimeInterface $currentDate,
) {}
public function getUserId(): int
{
return $this->userId;
}
public function getLastExecutionDateTime(): \DateTimeInterface
{
return $this->lastExecutionDate;
}
public function getCurrentDateTime(): \DateTimeInterface
{
return $this->currentDate;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages;
readonly class SendImmediateNotificationEmailMessage
{
public function __construct(
private int $notificationId,
private int $addresseeId,
) {}
public function getNotificationId(): int
{
return $this->notificationId;
}
public function getAddresseeId(): int
{
return $this->addresseeId;
}
}

View File

@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment; 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\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\TranslatorInterface; 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 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 = []; $uniqueDests = [];
foreach ($dests as $dest) { foreach ($dests as $dest) {
@ -69,55 +79,147 @@ class NotificationMailer
*/ */
public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void
{ {
$this->sendNotificationEmailsToAddresses($notification); $this->sendNotificationEmailsToAddressees($notification);
$this->sendNotificationEmailsToAddressesEmails($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 return;
{ }
foreach ($notification->getAddressees() as $addressee) {
foreach ($notification->getAllAddressees() as $addressee) {
if (null === $addressee->getEmail()) { if (null === $addressee->getEmail()) {
continue; continue;
} }
if ($notification->isSystem()) { $this->processNotificationForAddressee($notification, $addressee);
$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,
]);
}
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 $email
->subject($notification->getTitle()) ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
->to($addressee->getEmail()); ->context([
'notification' => $notification,
try { 'dest' => $addressee,
$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(),
]); ]);
} }
$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 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 = new TemplatedEmail();
$email $email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig')

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class NotificationByUserFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'notif-by-user';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.by-user');
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Contracts\Translation\TranslatableInterface;
interface NotificationFlagProviderInterface
{
public function getFlag(): string;
public function getLabel(): TranslatableInterface;
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class WorkflowTransitionNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'workflow-trans-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.workflow-trans');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
final readonly class NotificationFlagManager
{
/**
* @var array<NotificationFlagProviderInterface>
*/
private array $notificationFlagProviders;
public function __construct(
iterable $notificationFlagProviders,
) {
$this->notificationFlagProviders = iterator_to_array($notificationFlagProviders);
}
public function getAllNotificationFlagProviders(): array
{
return $this->notificationFlagProviders;
}
}

View File

@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository
return $qb; return $qb;
} }
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder private function queryByAddressee(User $addressee): QueryBuilder
{ {
$qb = $this->repository->createQueryBuilder('n'); $qb = $this->repository->createQueryBuilder('n');
$qb $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); ->setParameter('addressee', $addressee);
return $qb; return $qb;
@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository
return $nq->getResult(); 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();
}
} }

View File

@ -18,8 +18,9 @@
{%- endif -%} {%- endif -%}
{%- endblock form_label %} {%- endblock form_label %}
{# this has been rewritten for chill #}
{% block form_label_class -%} {% 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 %} {%- endblock form_label_class %}
{# Rows #} {# Rows #}

View File

@ -69,41 +69,44 @@
{% endif %} {% endif %}
</li> </li>
{% endif %} {% endif %}
{% if c.notification.addressees|length > 0 %} {% if c.notification.addressees|length > 0 or c.notification.addresseeUserGroups|length > 0 %}
<li class="notification-to"> <li class="notification-to">
{% if c.notification_cc is defined %} {% if c.notification_cc is defined %}
{% if c.notification_cc %} {% if c.notification_cc %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_cc' | trans }}"> <abbr title="{{ 'notification.sent_cc' | trans }}">
{{ "notification.cc" | trans }} : {{ "notification.cc" | trans }} :
</abbr> </abbr>
</span> </span>
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to' | trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ "notification.to" | trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to' | trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ "notification.to" | trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% for a in c.notification.addressees %} {% for a in c.notification.addressees %}
<span class="badge-user"> <span class="badge-user">
{{ a | chill_entity_render_string({'at_date': c.notification.date}) }} {{ a | chill_entity_render_string({'at_date': c.notification.date}) }}
</span> </span>
{% endfor %} {% endfor %}
{% for a in c.notification.addressesEmails %} {% for a in c.notification.addressesEmails %}
<span <span
class="badge-user" class="badge-user"
title="{{ 'notification.Email with access link'|trans|e('html_attr') }}" title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
> >
{{ a }} {{ a }}
</span> </span>
{% endfor %}
{% for ug in c.notification.addresseeUserGroups %}
{{ ug|chill_entity_render_box }}
{% endfor %} {% endfor %}
</li> </li>
{% endif %} {% endif %}

View File

@ -21,8 +21,6 @@
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{{ form_row(form.addressesEmails) }}
{% include handler.template(notification) with handler.templateData(notification) %} {% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row"> <div class="mb-3 row">

View File

@ -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 %}

View File

@ -20,7 +20,7 @@
{% extends "@ChillMain/layout.html.twig" %} {% extends "@ChillMain/layout.html.twig" %}
{% block title %}{{"My profile"|trans}}{% endblock %} {% block title %}{{"user.profile.title"|trans}}{% endblock %}
{% block content %} {% block content %}
<div class="justify-content-center col-10"> <div class="justify-content-center col-10">
@ -45,9 +45,35 @@
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.phonenumber) }} {{ form_row(form.phonenumber) }}
<h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2>
<table class="table table-striped align-middle">
<thead>
<tr>
<th>{{ 'notification.flags.type'|trans }}</th>
<th class="text-center">{{ 'notification.flags.preferences.immediate_email'|trans }}</th>
<th class="text-center">{{ 'notification.flags.preferences.daily_email'|trans }}</th>
</tr>
</thead>
<tbody class="table-hover table-group-divider">
{% for flag in form.notificationFlags %}
<tr>
<td class="col-sm-6">
{{ form_label(flag, null, {'label_attr': {'div_col_width': false}}) }}
</td>
<td class="text-center">
{{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td>
<td class="text-center">
{{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="record_actions"> <ul class="record_actions">
<li> <li>
{{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }} <button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li> </li>
</ul> </ul>

View File

@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code. * 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\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
@ -49,8 +49,8 @@ final class NotificationTest extends KernelTestCase
$notification = new Notification(); $notification = new Notification();
$notification->addAddressee($user1 = new User()); $notification->addAddressee($user1 = new User());
$notification->addAddressee($user2 = new User()); $notification->addAddressee($user2 = new User());
$notification->getAddressees()->add($user3 = new User()); $notification->addAddressee($user3 = new User());
$notification->getAddressees()->add($user4 = new User()); $notification->addAddressee($user4 = new User());
$this->assertCount(4, $notification->getAddressees()); $this->assertCount(4, $notification->getAddressees());
@ -85,6 +85,30 @@ final class NotificationTest extends KernelTestCase
$this->assertNotContains('other', $notification->getAddressesEmailsAdded()); $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 * @dataProvider generateNotificationData
*/ */

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Notification\Email;
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Run functional test on the cronjob.
*
* @internal
*
* @coversNothing
*/
class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
{
private DailyNotificationDigestCronjob $dailyNotificationDigestCronjob;
protected function setUp(): void
{
self::bootKernel();
$this->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'
);
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Notification\Email;
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @internal
*
* @coversNothing
*/
class DailyNotificationDigestCronJobTest extends TestCase
{
private ClockInterface $clock;
private Connection $connection;
private MessageBusInterface $messageBus;
private LoggerInterface $logger;
private DailyNotificationDigestCronjob $cronjob;
protected function setUp(): void
{
$this->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],
];
}
}

View File

@ -17,13 +17,18 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\Email\NotificationMailer; use Chill\MainBundle\Notification\Email\NotificationMailer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Translation\Translator; use Symfony\Component\Translation\Translator;
use Symfony\Contracts\Translation\TranslatorInterface;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
/** /**
* @internal * @internal
@ -112,13 +117,149 @@ class NotificationMailerTest extends TestCase
$mailer->postPersistComment($comment, new PostPersistEventArgs($comment, $objectManager->reveal())); $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( private function buildNotificationMailer(
?MailerInterface $mailer = null, ?MailerInterface $mailer = null,
?MessageBusInterface $messageBus = null,
?TranslatorInterface $translator = null,
): NotificationMailer { ): NotificationMailer {
return new NotificationMailer( return new NotificationMailer(
$mailer, $mailer ?? $this->prophesize(MailerInterface::class)->reveal(),
new NullLogger(), new NullLogger(),
new Translator('fr') $messageBus ?? $this->prophesize(MessageBusInterface::class)->reveal(),
$translator ?? new Translator('fr')
); );
} }
} }

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Notification\FlagProviders\WorkflowTransitionNotificationFlagProvider;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -125,7 +126,8 @@ class NotificationOnTransition implements EventSubscriberInterface
->setRelatedEntityClass(EntityWorkflow::class) ->setRelatedEntityClass(EntityWorkflow::class)
->setTitle($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context)) ->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)) ->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); $this->entityManager->persist($notification);
} }
} }

View File

@ -139,6 +139,11 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\UserProfileType: ~
Chill\MainBundle\Form\AbsenceType: ~ Chill\MainBundle\Form\AbsenceType: ~
Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~ Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~
Chill\MainBundle\Form\RegroupmentType: ~ Chill\MainBundle\Form\RegroupmentType: ~

View File

@ -12,6 +12,10 @@ services:
arguments: arguments:
$routeParameters: '%chill_main.notifications%' $routeParameters: '%chill_main.notifications%'
Chill\MainBundle\Notification\NotificationFlagManager:
arguments:
$notificationFlagProviders: !tagged_iterator chill_main.notification_flag_provider
Chill\MainBundle\Notification\NotificationHandlerManager: Chill\MainBundle\Notification\NotificationHandlerManager:
arguments: arguments:
$handlers: !tagged_iterator chill_main.notification_handler $handlers: !tagged_iterator chill_main.notification_handler
@ -55,14 +59,6 @@ services:
lazy: true lazy: true
method: 'postPersistNotification' 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' name: 'doctrine.orm.entity_listener'
event: 'postPersist' event: 'postPersist'

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250610102953 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add notification flags property to User';
}
public function up(Schema $schema): void
{
$this->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);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250618115938 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add type property to notifications';
}
public function up(Schema $schema): void
{
$this->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);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250623120824 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add addressee user groups to notifications';
}
public function up(Schema $schema): void
{
$this->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);
}
}

View File

@ -49,6 +49,12 @@ notification:
other {# commentaires} other {# commentaires}
} }
daily_notifications: >-
{notification_count, plural,
=1 {Voici votre notification du jour :}
other {Voici vos # notifications du jour :}
}
workflow: workflow:
My workflows with counter: >- My workflows with counter: >-
{wc, plural, {wc, plural,

View File

@ -52,9 +52,10 @@ user:
current_user: Utilisateur courant current_user: Utilisateur courant
profile: profile:
title: Mon profil 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 job: Pas de métier assigné
no scope: Pas de cercle assigné no scope: Pas de cercle assigné
notification_preferences: Préférences pour mes notifications
user_group: user_group:
inactive: Inactif 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 CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows
notification: notification:
Daily Notification Digest: Résumé des notifications quotidiennes
Notification: Notification Notification: Notification
Notifications: Notifications Notifications: Notifications
My own notifications: Mes 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. 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 Remove an email: Supprimer l'adresse email
Email with access link: Adresse email ayant reçu un lien d'accès 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_read: Marquer comme lu
mark_as_unread: Marquer comme non-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_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder
CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés
export: export:
role: role:
export_role: Exports export_role: Exports

View File

@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\MainBundle\Notification\NotificationPersisterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent;
use Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface; 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', [ ->setMessage($this->engine->render('@ChillPerson/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig', [
'oldPersonLocation' => $person, 'oldPersonLocation' => $person,
'period' => $period, 'period' => $period,
])); ]))
->setType(PersonAddressMoveNotificationFlagProvider::FLAG);
$this->notificationPersister->persist($notification); $this->notificationPersister->persist($notification);
} }

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\MainBundle\Notification\NotificationPersisterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider;
use Doctrine\Persistence\Event\LifecycleEventArgs; use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
@ -73,7 +74,8 @@ class UserRefEventSubscriber implements EventSubscriberInterface
'accompanyingCourse' => $period, 'accompanyingCourse' => $period,
] ]
)) ))
->addAddressee($period->getUser()); ->addAddressee($period->getUser())
->setType(DesignatedReferrerNotificationFlagProvider::FLAG);
$this->notificationPersister->persist($notification); $this->notificationPersister->persist($notification);
} }

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle; namespace Chill\PersonBundle;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface; use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface;
use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass; use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass;
use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface; use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface;
@ -35,5 +36,7 @@ class ChillPersonBundle extends Bundle
->addTag('chill_person.person_move_handler'); ->addTag('chill_person.person_move_handler');
$container->registerForAutoconfiguration(CustomizeListPersonHelperInterface::class) $container->registerForAutoconfiguration(CustomizeListPersonHelperInterface::class)
->addTag('chill_person.list_person_customizer'); ->addTag('chill_person.list_person_customizer');
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
->addTag('chill_main.notification_flag_provider');
} }
} }

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Notification\FlagProviders;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class DesignatedReferrerNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'referrer-acc-course-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.referrer-acc-course');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Notification\FlagProviders;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class PersonAddressMoveNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'person-move-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.person-address-move');
}
}

View File

@ -1,4 +1,8 @@
services: services:
_defaults:
autowire: true
autoconfigure: true
Chill\PersonBundle\Notification\AccompanyingPeriodNotificationHandler: Chill\PersonBundle\Notification\AccompanyingPeriodNotificationHandler:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
@ -8,3 +12,5 @@ services:
Chill\PersonBundle\Notification\AccompanyingPeriodWorkEvaluationDocumentNotificationHandler: Chill\PersonBundle\Notification\AccompanyingPeriodWorkEvaluationDocumentNotificationHandler:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider: ~
Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider: ~