feat: configurable recipient list for notifications of role nominations

This commit is contained in:
2026-09-16 09:21:04 +02:00
parent 6a8328a564
commit 5d7ffd7d58
5 changed files with 22 additions and 50 deletions
+2
View File
@@ -96,6 +96,8 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
ACCOMMODATION_INQUIRY_EMAIL=[email protected] ACCOMMODATION_INQUIRY_EMAIL=[email protected]
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
APP_ROLE_NOMINATION_EMAILS=
# Global common defaults # Global common defaults
APP_SEASON_WINTER_FROM=2026-10-01 APP_SEASON_WINTER_FROM=2026-10-01
+5
View File
@@ -15,6 +15,7 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# MailJet list ids and their labels # MailJet list ids and their labels
mailjet_lists: mailjet_lists:
@@ -311,6 +312,10 @@ services:
arguments: arguments:
$domains: '%employee_email_domains%' $domains: '%employee_email_domains%'
App\MessageHandler\RoleNominationHandler:
arguments:
$notificationRecipients: '%role_nomination_notification_emails%'
App\Service\DomainConfigProvider: App\Service\DomainConfigProvider:
arguments: arguments:
$domainConfig: '%domain_config%' $domainConfig: '%domain_config%'
+6 -12
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; 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 * 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. * SMTP latency and SMTP failures into the login path.
@@ -22,11 +22,15 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsMessageHandler] #[AsMessageHandler]
final class RoleNominationHandler final class RoleNominationHandler
{ {
/**
* @param string[] $notificationRecipients
*/
public function __construct( public function __construct(
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Mailer $mailer, private readonly Mailer $mailer,
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator, private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
private readonly LoggerInterface $authLogger, private readonly LoggerInterface $authLogger,
private readonly array $notificationRecipients,
) { ) {
} }
@@ -43,19 +47,9 @@ final class RoleNominationHandler
return; return;
} }
$recipients = array_values(array_filter(array_map( $recipients = $this->notificationRecipients;
static fn ($admin): ?string => $admin->getEmail(),
$this->userRepository->findAdministrators(),
)));
if ([] === $recipients) { 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; return;
} }
-26
View File
@@ -71,32 +71,6 @@ class UserRepository extends ServiceEntityRepository
->getResult(); ->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 * 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 * a booking is still assigned to even after losing the role — otherwise a booking assigned
@@ -16,8 +16,8 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* The notification goes to the people who can act on it, and to nobody else: an account merely * The notification goes to the addresses configured in config/services.yaml, and to nobody else:
* nominated for ROLE_ADMIN must not be told about other people's nominations. * the audience does not follow whoever currently holds ROLE_ADMIN.
*/ */
class RoleNominationHandlerTest extends TestCase class RoleNominationHandlerTest extends TestCase
{ {
@@ -31,14 +31,11 @@ class RoleNominationHandlerTest extends TestCase
private ?int $generatedReferenceType = null; private ?int $generatedReferenceType = null;
public function testMailsEveryAdministrator(): void public function testMailsTheConfiguredRecipients(): void
{ {
$handler = $this->handler( $handler = $this->handler(
$this->user(7, '[email protected]'), $this->user(7, '[email protected]'),
[ ['[email protected]', '[email protected]'],
$this->user(1, '[email protected]', [Role::ADMIN]),
$this->user(2, '[email protected]', [Role::ADMIN]),
],
); );
$handler(new RoleNominationMessage(7, [Role::ADMIN])); $handler(new RoleNominationMessage(7, [Role::ADMIN]));
@@ -57,14 +54,14 @@ class RoleNominationHandlerTest extends TestCase
public function testAMissingAccountIsANoOp(): void public function testAMissingAccountIsANoOp(): void
{ {
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]); $handler = $this->handler(null, ['[email protected]']);
$handler(new RoleNominationMessage(7, [Role::ADMIN])); $handler(new RoleNominationMessage(7, [Role::ADMIN]));
self::assertNull($this->sentOptions); self::assertNull($this->sentOptions);
} }
public function testWithoutAnAdministratorNothingIsSent(): void public function testWithoutAConfiguredRecipientNothingIsSent(): void
{ {
$handler = $this->handler($this->user(7, '[email protected]'), []); $handler = $this->handler($this->user(7, '[email protected]'), []);
@@ -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 = $this->createStub(UserRepository::class);
$userRepository->method('find')->willReturn($nominee); $userRepository->method('find')->willReturn($nominee);
$userRepository->method('findAdministrators')->willReturn($administrators);
$mailer = $this->createStub(Mailer::class); $mailer = $this->createStub(Mailer::class);
$mailer $mailer
@@ -123,6 +119,7 @@ class RoleNominationHandlerTest extends TestCase
$mailer, $mailer,
new RoleApprovalUrlGenerator($urlGenerator), new RoleApprovalUrlGenerator($urlGenerator),
$this->createStub(LoggerInterface::class), $this->createStub(LoggerInterface::class),
$recipients,
); );
} }
} }