From 5d7ffd7d586d8e170cbbc3dba6c66a4704062b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 16 Sep 2026 09:21:04 +0200 Subject: [PATCH] feat: configurable recipient list for notifications of role nominations --- .env | 2 ++ config/services.yaml | 5 ++++ src/MessageHandler/RoleNominationHandler.php | 18 +++++-------- src/Repository/UserRepository.php | 26 ------------------- .../RoleNominationHandlerTest.php | 21 +++++++-------- 5 files changed, 22 insertions(+), 50 deletions(-) diff --git a/.env b/.env index 695f403..0f2ff8e 100644 --- a/.env +++ b/.env @@ -96,6 +96,8 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 APP_DEFAULT_EMAIL_FROM=info@ep-reisen.de APP_DEFAULT_EMAIL_TO=info@ep-reisen.de ACCOMMODATION_INQUIRY_EMAIL=gruppen@ep-reisen.de +# Comma-separated; recipients of the role nomination notification. Empty means disable notification. +APP_ROLE_NOMINATION_EMAILS= # Global common defaults APP_SEASON_WINTER_FROM=2026-10-01 diff --git a/config/services.yaml b/config/services.yaml index e905dda..82de8b4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -15,6 +15,7 @@ parameters: default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' + role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%' # MailJet list ids and their labels mailjet_lists: @@ -311,6 +312,10 @@ services: arguments: $domains: '%employee_email_domains%' + App\MessageHandler\RoleNominationHandler: + arguments: + $notificationRecipients: '%role_nomination_notification_emails%' + App\Service\DomainConfigProvider: arguments: $domainConfig: '%domain_config%' diff --git a/src/MessageHandler/RoleNominationHandler.php b/src/MessageHandler/RoleNominationHandler.php index 40b8a10..6ab32de 100644 --- a/src/MessageHandler/RoleNominationHandler.php +++ b/src/MessageHandler/RoleNominationHandler.php @@ -14,7 +14,7 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** - * Tells the administrators that an account is waiting for a role to be approved. + * Tells the configured recipients that an account is waiting for a role to be approved. * * Runs off the request: mail is routed sync in this application, so sending it inline would put * SMTP latency and SMTP failures into the login path. @@ -22,11 +22,15 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[AsMessageHandler] final class RoleNominationHandler { + /** + * @param string[] $notificationRecipients + */ public function __construct( private readonly UserRepository $userRepository, private readonly Mailer $mailer, private readonly RoleApprovalUrlGenerator $approvalUrlGenerator, private readonly LoggerInterface $authLogger, + private readonly array $notificationRecipients, ) { } @@ -43,19 +47,9 @@ final class RoleNominationHandler return; } - $recipients = array_values(array_filter(array_map( - static fn ($admin): ?string => $admin->getEmail(), - $this->userRepository->findAdministrators(), - ))); + $recipients = $this->notificationRecipients; if ([] === $recipients) { - // Worth a warning rather than a silent return: nobody can approve the nomination, and - // without this line nobody would find out that the notification goes nowhere. - $this->authLogger->warning('No administrator to notify about a role nomination', [ - 'userId' => $message->userId, - 'roles' => $message->roles, - ]); - return; } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2bdfb3a..2d4ed57 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -71,32 +71,6 @@ class UserRepository extends ServiceEntityRepository ->getResult(); } - /** - * The administrators who can approve a role nomination. - * - * The quote-anchored needle is load-bearing. ROLE_ADMIN_PENDING lives in the same JSON column, - * and an unanchored '%ROLE_ADMIN%' would match it — which would mail the very people whose own - * nomination is unapproved about other people's nominations. The result is filtered through - * Role::effectiveOnly() as well, so the guarantee does not rest on the LIKE alone. - * - * @return User[] - */ - public function findAdministrators(): array - { - /** @var User[] $candidates */ - $candidates = $this->createQueryBuilder('u') - ->andWhere('u.roles LIKE :admin') - ->setParameter('admin', '%"'.Role::ADMIN.'"%') - ->orderBy('u.email', 'ASC') - ->getQuery() - ->getResult(); - - return array_values(array_filter( - $candidates, - static fn (User $user): bool => \in_array(Role::ADMIN, Role::effectiveOnly($user->getRoles()), true), - )); - } - /** * Everyone worth filtering an accommodation booking by: current groups staff, plus whoever * a booking is still assigned to even after losing the role — otherwise a booking assigned diff --git a/tests/MessageHandler/RoleNominationHandlerTest.php b/tests/MessageHandler/RoleNominationHandlerTest.php index ba2be39..043ddef 100644 --- a/tests/MessageHandler/RoleNominationHandlerTest.php +++ b/tests/MessageHandler/RoleNominationHandlerTest.php @@ -16,8 +16,8 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** - * The notification goes to the people who can act on it, and to nobody else: an account merely - * nominated for ROLE_ADMIN must not be told about other people's nominations. + * The notification goes to the addresses configured in config/services.yaml, and to nobody else: + * the audience does not follow whoever currently holds ROLE_ADMIN. */ class RoleNominationHandlerTest extends TestCase { @@ -31,14 +31,11 @@ class RoleNominationHandlerTest extends TestCase private ?int $generatedReferenceType = null; - public function testMailsEveryAdministrator(): void + public function testMailsTheConfiguredRecipients(): void { $handler = $this->handler( $this->user(7, 'nominee@example.org'), - [ - $this->user(1, 'first@ep-reisen.de', [Role::ADMIN]), - $this->user(2, 'second@ep-reisen.de', [Role::ADMIN]), - ], + ['first@ep-reisen.de', 'second@ep-reisen.de'], ); $handler(new RoleNominationMessage(7, [Role::ADMIN])); @@ -57,14 +54,14 @@ class RoleNominationHandlerTest extends TestCase public function testAMissingAccountIsANoOp(): void { - $handler = $this->handler(null, [$this->user(1, 'admin@ep-reisen.de', [Role::ADMIN])]); + $handler = $this->handler(null, ['admin@ep-reisen.de']); $handler(new RoleNominationMessage(7, [Role::ADMIN])); self::assertNull($this->sentOptions); } - public function testWithoutAnAdministratorNothingIsSent(): void + public function testWithoutAConfiguredRecipientNothingIsSent(): void { $handler = $this->handler($this->user(7, 'nominee@example.org'), []); @@ -88,13 +85,12 @@ class RoleNominationHandlerTest extends TestCase } /** - * @param User[] $administrators + * @param string[] $recipients */ - private function handler(?User $nominee, array $administrators): RoleNominationHandler + private function handler(?User $nominee, array $recipients): RoleNominationHandler { $userRepository = $this->createStub(UserRepository::class); $userRepository->method('find')->willReturn($nominee); - $userRepository->method('findAdministrators')->willReturn($administrators); $mailer = $this->createStub(Mailer::class); $mailer @@ -123,6 +119,7 @@ class RoleNominationHandlerTest extends TestCase $mailer, new RoleApprovalUrlGenerator($urlGenerator), $this->createStub(LoggerInterface::class), + $recipients, ); } }