Implement cronjob logic for sending daily digest emails for notifications

This commit is contained in:
Julie Lenaerts 2025-07-04 11:27:44 +02:00
parent 1a99906339
commit 48bc019932
10 changed files with 249 additions and 187 deletions

View File

@ -466,4 +466,15 @@ class Notification implements TrackUpdateInterface
{ {
return $this->type; return $this->type;
} }
public function isSendImmediately(User $user): bool
{
$notificationFlags = $user->getNotificationFlags();
$notificationType = $this->getType();
// Check if the user has a preference for this notification type
$emailPreference = $notificationFlags[$notificationType] ?? null;
return 'immediate-email' === $emailPreference;
}
} }

View File

@ -0,0 +1,97 @@
<?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();
$lastExecution = isset($lastExecutionData['last_execution'])
? new \DateTimeImmutable($lastExecutionData['last_execution'])
: $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('Y-m-d H:i:s'));
$sqlStatement->bindValue('now', $now->format('Y-m-d H:i:s'));
$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'),
'current_time' => $now->format('Y-m-d H:i:s'),
]);
return [
'last_execution' => $now->format('Y-m-d H:i:s'),
];
}
}

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->getLastExecutionDate();
$currentDate = $message->getCurrentDate();
$user = $this->userRepository->find($userId);
if (null === $user) {
$this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [
'user_id' => $userId,
]);
return;
}
// Get all notifications for this user between last execution and current date
$notifications = $this->notificationRepository->findNotificationsForUserBetweenDates(
$userId,
$lastExecutionDate,
$currentDate
);
// Filter out notifications that were sent immediately
$dailyNotifications = array_filter($notifications, function ($notification) use ($user) {
return !$notification->isSendImmediately($user);
});
if (empty($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

@ -1,70 +0,0 @@
<?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

@ -1,66 +0,0 @@
<?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

@ -11,14 +11,26 @@ declare(strict_types=1);
namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages; namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages;
class SendDailyDigestMessage readonly class ScheduleDailyNotificationDigestMessage
{ {
public function __construct( public function __construct(
private readonly int $userId, private int $userId,
private \DateTimeInterface $lastExecutionDate,
private \DateTimeInterface $currentDate,
) {} ) {}
public function getUserId(): int public function getUserId(): int
{ {
return $this->userId; return $this->userId;
} }
public function getLastExecutionDate(): \DateTimeInterface
{
return $this->lastExecutionDate;
}
public function getCurrentDate(): \DateTimeInterface
{
return $this->currentDate;
}
} }

View File

@ -1,30 +0,0 @@
<?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

@ -13,7 +13,6 @@ 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\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage; use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs;
@ -104,15 +103,14 @@ readonly class NotificationMailer
$notificationFlags = $addressee->getNotificationFlags(); $notificationFlags = $addressee->getNotificationFlags();
$notificationType = $notification->getType(); $notificationType = $notification->getType();
$emailPreference = $notificationFlags[$notificationType->value] ?? null; $emailPreference = $notificationFlags[$notificationType] ?? null;
match ($emailPreference) { match ($emailPreference) {
'immediate-email' => $this->scheduleImmediateEmail($notification, $addressee), 'immediate-email' => $this->scheduleImmediateEmail($notification, $addressee),
'daily-email' => $this->scheduleDailyEmail($notification, $addressee), // don't do this (the cronjob will handle them later)
default => $this->logger->debug('[NotificationMailer] No email preference set for notification type', [ default => $this->logger->debug('[NotificationMailer] No email preference set for notification type', [
'notification_id' => $notification->getId(), 'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(), 'addressee_email' => $addressee->getEmail(),
'notification_type' => $notificationType->value, 'notification_type' => $notificationType,
'preference' => $emailPreference, 'preference' => $emailPreference,
]), ]),
}; };
@ -133,21 +131,6 @@ readonly class NotificationMailer
]); ]);
} }
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. * This method sends the email but is now called by the immediate notification email message handler.
* *
@ -195,6 +178,7 @@ readonly class NotificationMailer
/** /**
* Send daily digest email with multiple notifications to a user. * Send daily digest email with multiple notifications to a user.
*
* @throws TransportExceptionInterface * @throws TransportExceptionInterface
*/ */
public function sendDailyDigest($user, array $notifications): void public function sendDailyDigest($user, array $notifications): void

View File

@ -393,4 +393,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

@ -105,6 +105,29 @@ 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->assertFalse($notification->isSendImmediately($user), 'Should return false when no notification flags are set');
// immediate-email preference
$user->setNotificationFlags(['test_notification_type' => 'immediate-email']);
$this->assertTrue($notification->isSendImmediately($user), 'Should return true when preference is immediate-email');
// daily-email preference
$user->setNotificationFlags(['test_notification_type' => 'daily-email']);
$this->assertFalse($notification->isSendImmediately($user), 'Should return false when preference is daily-email');
// a different notification type
$notification->setType('other_notification_type');
$this->assertFalse($notification->isSendImmediately($user), 'Should return false when notification type does not match any preference');
}
/** /**
* @dataProvider generateNotificationData * @dataProvider generateNotificationData
*/ */