feat: configurable recipient list for notifications of role nominations
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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%'
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user