Compare commits

...

14 Commits

Author SHA1 Message Date
f88f1f1859 WIP add config for new messengers 2025-06-19 20:36:32 +02:00
e6cbba8c63 WIP implement immediate and daily email functionality 2025-06-19 20:23:43 +02:00
c2782be56a Remove person notification flag provider - non existant 2025-06-18 15:24:39 +02:00
cf391d60fe Use enums in flag providers and rename NotificationEnum to NotificationFlagEnum 2025-06-18 15:24:12 +02:00
acad9d1553 Set type upon creation of notifications by user 2025-06-18 14:48:06 +02:00
0a19255a22 Set type upon creation of automatic notifications 2025-06-18 14:30:23 +02:00
bef5dcce14 Change property on notification entity to type of NotificationEnum 2025-06-18 14:03:51 +02:00
33540f58d7 Implement datamapper to handle form data for notification flags 2025-06-18 11:10:06 +02:00
cf780b6e36 WIP add notification preferences table to user profile form 2025-06-10 15:55:01 +02:00
c9c565809a Create notification flag providers 2025-06-10 14:50:41 +02:00
c917c42789 Add flags property to User entity + migraiton 2025-06-10 14:31:21 +02:00
1c426f560e Add flags property to Notification entity + migraiton 2025-06-10 12:27:38 +02:00
5ee8a6bc82 Merge branch 'upgrade-ci-image' into 'master'
Update CI configuration to use `chill/base-image:8.3-edge` instead of the old PHP 8.2 base image

See merge request Chill-Projet/chill-bundles!834
2025-06-05 10:10:41 +00:00
47cf83ef93 Update CI configuration to use chill/base-image:8.3-edge instead of the old PHP 8.2 base image 2025-06-05 11:30:01 +02:00
38 changed files with 1113 additions and 63 deletions

View File

@@ -46,7 +46,7 @@ stages:
build:
stage: Composer install
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
image: chill/base-image:8.3-edge
before_script:
- composer config -g cache-dir "$(pwd)/.cache"
script:
@@ -61,7 +61,7 @@ build:
code_style:
stage: Tests
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
image: chill/base-image:8.3-edge
script:
- php-cs-fixer fix --dry-run -v --show-progress=none
cache:
@@ -74,7 +74,7 @@ code_style:
phpstan_tests:
stage: Tests
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
image: chill/base-image:8.3-edge
variables:
COMPOSER_MEMORY_LIMIT: 3G
before_script:
@@ -91,7 +91,7 @@ phpstan_tests:
rector_tests:
stage: Tests
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
image: chill/base-image:8.3-edge
before_script:
- bin/console cache:clear --env=dev
script:
@@ -132,7 +132,7 @@ lint:
unit_tests:
stage: Tests
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
image: chill/base-image:8.3-edge
variables:
COMPOSER_MEMORY_LIMIT: 3G
before_script:

View File

@@ -12,3 +12,7 @@ framework:
adapter: cache.adapter.redis
public: false
default_lifetime: 300
cache.daily_notifications:
adapter: cache.adapter.redis
public: true
default_lifetime: 90000 # 25 hours

View File

@@ -45,6 +45,27 @@ framework:
auto_setup: false
immediate_email:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%/priority'
options:
queue_name: immediate_notifications
exchange:
name: notifications
type: direct
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
daily_email:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: daily_notifications
exchange:
name: notifications
type: direct
# No automatic consumption - handled by cron job
routing:
# routes added by chill-bundles recipes
'Chill\CalendarBundle\Messenger\Message\CalendarRangeMessage': async
@@ -61,6 +82,9 @@ framework:
'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority
'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async
'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async
'Chill\MainBundle\Notification\Email\SendImmediateNotificationEmailMessage': immediate_email
'Chill\MainBundle\Notification\Email\ScheduleDailyNotificationEmailMessage': daily_email
'Chill\MainBundle\Notification\Email\SendDailyDigestMessage': daily_email
# end of routes added by chill-bundles recipes
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -11,8 +11,10 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\ActivityBundle\Entity\Activity;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Form\NotificationType;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
@@ -22,6 +24,9 @@ use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Security\Authorization\NotificationVoter;
use Chill\MainBundle\Security\ChillSecurity;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -53,11 +58,29 @@ class NotificationController extends AbstractController
throw new BadRequestHttpException('missing entityId parameter');
}
$notificationType = '';
switch ($request->query->get('entityClass')) {
case Activity::class:
$notificationType = NotificationFlagEnum::ACTIVITY;
break;
case AccompanyingPeriod::class:
$notificationType = NotificationFlagEnum::ACC_COURSE;
break;
case AccompanyingPeriodWork::class:
$notificationType = NotificationFlagEnum::ACC_COURSE_WORK;
break;
case AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument::class:
$notificationType = NotificationFlagEnum::ACC_COURSE_WORK_EVAL_DOC;
break;
}
$notification = new Notification();
$notification
->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId'))
->setSender($this->security->getUser());
->setSender($this->security->getUser())
->setType($notificationType);
$tos = $request->query->all('tos');

View File

@@ -11,14 +11,12 @@ 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 +39,21 @@ 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->add('submit', SubmitType::class);
$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->persist($user);
$em->flush();
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile');
}
@@ -60,13 +63,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')]);
}
}

View File

@@ -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;
@@ -24,7 +25,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])]
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 = [];
@@ -41,7 +42,7 @@ class Notification implements TrackUpdateInterface
*
* @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 +61,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 +85,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,12 +95,15 @@ 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, enumType: NotificationFlagEnum::class)]
private NotificationFlagEnum $type;
public function __construct()
{
$this->addressees = new ArrayCollection();
@@ -389,4 +393,16 @@ class Notification implements TrackUpdateInterface
return $this;
}
public function setType(NotificationFlagEnum $type): self
{
$this->type = $type;
return $this;
}
public function getType(): NotificationFlagEnum
{
return $this->type;
}
}

View File

@@ -0,0 +1,23 @@
<?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\Entity;
enum NotificationFlagEnum: string
{
case REFERRER_ACC_COURSE = 'referrer-acc-course-notif';
case PERSON_MOVE = 'person-move-notif';
case ACC_COURSE = 'acc-course-notif';
case WORKFLOW_TRANS = 'workflow-trans-notif';
case ACC_COURSE_WORK = 'acc-course-work-notif';
case ACC_COURSE_WORK_EVAL_DOC = 'acc-course-work-eval-doc-notif';
case ACTIVITY = 'activity-notif';
}

View File

@@ -116,6 +116,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[PhonenumberConstraint]
private ?PhoneNumber $phonenumber = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private $notificationFlags = [];
/**
* User constructor.
*/
@@ -613,4 +616,24 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this;
}
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;
}
}

View File

@@ -0,0 +1,78 @@
<?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 Symfony\Component\Form\DataMapperInterface;
final readonly class NotificationFlagDataMapper implements DataMapperInterface
{
private array $notificationFlagProviders;
public function __construct(array $notificationFlagProviders)
{
$this->notificationFlagProviders = $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('immediate-email', $viewData[$flag] ?? []);
$dailyEmailChecked = in_array('daily-email', $viewData[$flag] ?? []);
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 ($flagForm['immediate_email']->getData()) {
$viewData[$flag][] = 'immediate-email';
}
if ($flagForm['daily_email']->getData()) {
$viewData[$flag][] = 'daily-email';
}
if (empty($viewData[$flag])) {
$viewData[$flag][] = 'no-email';
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
<?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 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, // Keep this here for the individual checkboxes
])
->add('daily_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false, // Keep this here for the individual checkboxes
]);
}
}
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,70 @@
<?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\ScheduleDailyNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendDailyDigestMessage;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Contracts\Cache\CacheInterface;
readonly class ScheduleDailyNotificationEmailHandler
{
public function __construct(
private CacheInterface $dailyNotificationsCache,
private MessageBusInterface $messageBus,
private LoggerInterface $logger,
) {}
/**
* @throws InvalidArgumentException
*/
public function __invoke(ScheduleDailyNotificationEmailMessage $message): void
{
$userId = $message->getAddresseeId();
$notificationId = $message->getNotificationId();
// Store notification in cache grouped by user
$cacheKey = "daily_notifications_user_{$userId}";
$existingNotifications = $this->dailyNotificationsCache->get($cacheKey, function () {
return [];
});
$existingNotifications[] = $notificationId;
$this->dailyNotificationsCache->get($cacheKey, function () use ($existingNotifications) {
return $existingNotifications;
});
// Only send the daily digest message if this is the first notification for today otherwise it already exists
if (1 === count($existingNotifications)) {
$digestMessage = new SendDailyDigestMessage($userId);
// Calculate delay until next 9 AM
$now = new \DateTimeImmutable();
$nextNineAM = $now->modify('tomorrow 09:00');
$delay = $nextNineAM->getTimestamp() - $now->getTimestamp();
$this->messageBus->dispatch($digestMessage, [
new DelayStamp($delay * 1000),
]);
}
$this->logger->info('[ScheduleDailyNotificationEmailHandler] Added notification to daily cache', [
'notification_id' => $notificationId,
'user_id' => $userId,
'total_pending' => count($existingNotifications),
]);
}
}

View File

@@ -0,0 +1,66 @@
<?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\SendDailyDigestMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsMessageHandler]
class SendDailyDigestHandler
{
public function __construct(
private readonly CacheInterface $dailyNotificationsCache,
private readonly NotificationRepository $notificationRepository,
private readonly UserRepository $userRepository,
private readonly NotificationMailer $notificationMailer,
private readonly LoggerInterface $logger,
) {}
/**
* @throws InvalidArgumentException
*/
public function __invoke(SendDailyDigestMessage $message): void
{
$userId = $message->getUserId();
$cacheKey = "daily_notifications_user_{$userId}";
$notificationIds = $this->dailyNotificationsCache->get($cacheKey, []);
if (empty($notificationIds)) {
$this->logger->info('[SendDailyDigestHandler] No notifications found for user', [
'user_id' => $userId,
]);
return;
}
$user = $this->userRepository->find($userId);
$notifications = $this->notificationRepository->findBy(['id' => $notificationIds]);
if ($user && !empty($notifications)) {
$this->notificationMailer->sendDailyDigest($user, $notifications);
// Clear the cache after sending
$this->dailyNotificationsCache->delete($cacheKey);
$this->logger->info('[SendDailyDigestHandler] Sent daily digest', [
'user_id' => $userId,
'notification_count' => count($notifications),
]);
}
}
}

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]
class SendImmediateNotificationEmailHandler
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly UserRepository $userRepository,
private readonly NotificationMailer $notificationMailer,
private readonly 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(),
]);
return;
}
if (null === $addressee) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [
'addressee_id' => $message->getAddresseeId(),
]);
return;
}
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(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

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 ScheduleDailyNotificationEmailMessage
{
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

@@ -0,0 +1,24 @@
<?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;
class SendDailyDigestMessage
{
public function __construct(
private readonly int $userId,
) {}
public function getUserId(): int
{
return $this->userId;
}
}

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,6 +13,8 @@ namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Psr\Log\LoggerInterface;
@@ -20,11 +22,12 @@ 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 readonly TranslatorInterface $translator) {}
public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void
{
@@ -80,38 +83,152 @@ class NotificationMailer
private function sendNotificationEmailsToAddresses(Notification $notification): void
{
if (null === $notification->getType()) {
$this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [
'notification_id' => $notification->getId(),
]);
return;
}
foreach ($notification->getAddressees() 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, $addressee): void
{
$notificationFlags = $addressee->getNotificationFlags();
$notificationType = $notification->getType();
$emailPreference = $notificationFlags[$notificationType->value] ?? null;
match ($emailPreference) {
'immediate-email' => $this->scheduleImmediateEmail($notification, $addressee),
'daily-email' => $this->scheduleDailyEmail($notification, $addressee),
default => $this->logger->debug('[NotificationMailer] No email preference set for notification type', [
'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(),
'notification_type' => $notificationType->value,
'preference' => $emailPreference,
]),
};
}
private function scheduleImmediateEmail(Notification $notification, $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(),
]);
}
private function scheduleDailyEmail(Notification $notification, $addressee): void
{
$message = new ScheduleDailyNotificationEmailMessage(
$notification->getId(),
$addressee->getId()
);
$this->messageBus->dispatch($message);
$this->logger->info('[NotificationMailer] Scheduled daily 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, $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, array $notifications): void
{
if (null === $user->getEmail() || empty($notifications)) {
return;
}
$email = new TemplatedEmail();
$email
->textTemplate('@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; // Re-throw so the message handler can handle the failure
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AccompanyingCourseNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::ACC_COURSE->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.acc-course');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AccompanyingCourseWorkEvalDocNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::ACC_COURSE_WORK_EVAL_DOC->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.acc-course-work-eval-doc');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AccompanyingCourseWorkNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::ACC_COURSE_WORK->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.acc-course-work');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class ActivityNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::ACTIVITY->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.activity');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class DesignatedReferrerNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::REFERRER_ACC_COURSE->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.referrer-acc-course');
}
}

View File

@@ -0,0 +1,23 @@
<?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;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('chill_main.notification_flag_provider')]
interface NotificationFlagProviderInterface
{
public function getFlag(): string;
public function getLabel(): TranslatableInterface;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class PersonAddressMoveNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::PERSON_MOVE->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.person-address-move');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Notification\FlagProviders;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class WorkflowTransitionNotificationFlagProvider implements NotificationFlagProviderInterface
{
public function getFlag(): string
{
return NotificationFlagEnum::WORKFLOW_TRANS->value;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.workflow-trans');
}
}

View File

@@ -0,0 +1,44 @@
<?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;
}
public function getNotificationFlagProviderByLabel(string $label): ?NotificationFlagProviderInterface
{
foreach ($this->notificationFlagProviders as $provider) {
if ($provider->getLabel() == $label) {
return $provider;
}
}
return null;
}
}

View File

@@ -0,0 +1,18 @@
# Résumé quotidien des notifications
Bonjour {{ user.name ?? user.email }},
Voici vos {{ notification_count }} notification{% if notification_count > 1 %}s{% endif %} du jour :
{% for notification in notifications %}
## {{ notification.title }}
{{ notification.message }}
Vous pouvez visualiser la notification et y répondre ici:
{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }}
{% endfor %}
--
Le logiciel Chill

View File

@@ -45,6 +45,32 @@
{{ form_start(form) }}
{{ form_row(form.phonenumber) }}
<h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2>
<table class="table table-bordered border-dark align-middle">
<thead>
<tr>
<th>{{ 'notification.flags.type'|trans }}</th>
<th>{{ 'notification.flags.preferences.immediate_email'|trans }}</th>
<th>{{ 'notification.flags.preferences.daily_email'|trans }}</th>
</tr>
</thead>
<tbody>
{% for flag in form.notificationFlags %}
<tr>
<td class="col-sm-6">
<label>{{ form_label(flag) }}</label>
</td>
<td>
{{ form_widget(flag.immediate_email) }}
</td>
<td>
{{ form_widget(flag.daily_email) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="record_actions">
<li>
{{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -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(NotificationFlagEnum::WORKFLOW_TRANS);
$this->entityManager->persist($notification);
}
}

View File

@@ -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: ~

View File

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

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
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,30 @@
<?php
declare(strict_types=1);
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)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_main_notification DROP type
SQL);
}
}

View File

@@ -51,9 +51,10 @@ Label: Nom
user:
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
@@ -715,6 +716,21 @@ notification:
mark_as_read: Marquer comme lu
mark_as_unread: Marquer comme non-lu
flags:
type: Type de notification
referrer-acc-course: Notification lors de la désignation comme référent
acc-course-work-eval-doc: Notification sur un document d'évaluation
acc-course-work: Notification sur un action d'accompagnement
activity: Notification sur un échange
acc-course: Notification sur un parcours d'accompagnement
person-address-move: Notification lors que l'usager qui localise un parcours a déménagé
person: Notification sur un usager
workflow-trans: Notification sur une transition d'un workflow
preferences:
immediate_email: Recevoir un email immédiatement
daily_email: Recevoir un récapitulatif quotidien
export:
address_helper:

View File

@@ -13,6 +13,7 @@ namespace Chill\PersonBundle\AccompanyingPeriod\Events;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Chill\MainBundle\Notification\NotificationPersisterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent;
@@ -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(NotificationFlagEnum::PERSON_MOVE);
$this->notificationPersister->persist($notification);
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\AccompanyingPeriod\Events;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationFlagEnum;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\NotificationPersisterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
@@ -73,7 +74,8 @@ class UserRefEventSubscriber implements EventSubscriberInterface
'accompanyingCourse' => $period,
]
))
->addAddressee($period->getUser());
->addAddressee($period->getUser())
->setType(NotificationFlagEnum::REFERRER_ACC_COURSE);
$this->notificationPersister->persist($notification);
}

View File

@@ -14,6 +14,15 @@
"config/routes/annotations.yaml"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.13",
"recipe": {