feat: let admins notify all users about updated access codes

This commit is contained in:
fromme committed 2026-10-15 10:49:09 +02:00
1 parent a957e0d662
commit d68b6b156c
16 files changed
+458 -59

No files matched your search

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccessCode;
use App\Controller\Traits\ConfirmedActionTrait;
use App\Htmx\HxRedirectResponse;
use App\Repository\UserRepository;
use App\Security\Role;
use App\Service\AccessCodeNotifier;
use App\Service\AuditLogger;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Tells the whole staff that the codes changed. Confirmed first, because a mail to everybody
* cannot be taken back -- and the confirmation says how many people that is.
*/
#[IsGranted(Role::ADMIN)]
final class NotifyController extends AbstractController
{
use ConfirmedActionTrait;
private const CSRF_TOKEN_ID = 'notify-access-codes';
public function __construct(
private readonly AccessCodeNotifier $notifier,
private readonly UserRepository $users,
private readonly AuditLogger $audit,
) {
}
#[Route('/admin/access-codes/notify', name: 'app_admin_access_code_notify', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
if (!$request->isMethod(Request::METHOD_POST)) {
return $this->render('admin/_modal_access_code_notify.html.twig', [
'recipients' => $this->users->countActive(),
'csrf_token_id' => self::CSRF_TOKEN_ID,
]);
}
$this->denyUnlessCsrfValid($request, self::CSRF_TOKEN_ID);
$report = $this->notifier->codesUpdated();
$this->audit->accessCodesAnnounced($report->sent, $report->failed);
if (0 === $report->failed) {
$this->addFlash('success', \sprintf('Benachrichtigung an %d Mitarbeitende versendet.', $report->sent));
} else {
$this->addFlash('warning', \sprintf(
'Benachrichtigung an %d Mitarbeitende versendet, %d konnten nicht erreicht werden.',
$report->sent,
$report->failed,
));
}
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
/**
* Every mail this application sends goes through here.
*
* The counterpart of myep's App\Email\Mailer, with three deliberate differences. Arguments are
* typed rather than an OptionsResolver array, since there are only four of them and all are
* required. Every mail is multipart, with a hand-written text part beside the MyE&P layout. And a
* failure is logged and counted rather than rethrown -- see below.
*
* **Sending never throws.** Mail here is always the side effect of something that has already
* happened -- a booking committed, a code rotated -- and the person who caused it should not be
* shown an error about a mail server. The caller gets a SendReport instead and decides whether
* the failures are worth mentioning.
*
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
* list, and a single bad address loses its own mail rather than everybody's.
*
* Sending is synchronous, inside the request: no messenger worker runs on the server (see
* messenger.yaml). At the fifty-odd accounts this application has, that is a few seconds at
* most. Routing SendEmailMessage to `async` once a worker runs needs no change here.
*/
final readonly class Mailer
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
private string $sender,
) {
}
/**
* @param iterable<Address|string> $recipients
* @param string $template the template pair's basename; both .html.twig
* and .txt.twig of it are rendered
* @param array<string, mixed> $context
*/
public function send(iterable $recipients, string $subject, string $template, array $context = []): SendReport
{
$sent = 0;
$failed = 0;
foreach ($recipients as $recipient) {
$email = (new TemplatedEmail())
->from(new Address($this->sender, 'E&P Platz'))
->to($recipient)
->subject($subject)
->htmlTemplate($template.'.html.twig')
->textTemplate($template.'.txt.twig')
->context($context);
try {
$this->mailer->send($email);
++$sent;
} catch (\Throwable $e) {
// Caught per recipient, so one unreachable address does not cost the others theirs.
// \Throwable rather than TransportExceptionInterface because the templates are
// rendered inside send(): a typo in one of them would otherwise surface as an
// error in whatever action triggered the mail.
++$failed;
$this->logger->error('Email could not be sent.', [
'subject' => $subject,
'recipient' => $recipient instanceof Address ? $recipient->getAddress() : $recipient,
'exception' => $e,
]);
}
}
return new SendReport($sent, $failed);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Email;
/**
* What came of a Mailer::send(): how many recipients the mail reached the transport for, and how
* many it did not. The failures themselves are in the log.
*/
final readonly class SendReport
{
public function __construct(
public int $sent,
public int $failed,
) {
}
}
+16
View File
@@ -30,4 +30,20 @@ class UserRepository extends ServiceEntityRepository
{
return $this->findOneBy(['sub' => $sub]);
}
/**
* Everybody who can currently sign in -- the audience of a mail meant for the whole staff.
* A locked account is left out: its holder could not follow the link in it anyway.
*
* @return list<User>
*/
public function findActive(): array
{
return $this->findBy(['disabledAt' => null], ['name' => 'ASC']);
}
public function countActive(): int
{
return $this->count(['disabledAt' => null]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Email\SendReport;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Mime\Address;
/**
* Tells the whole staff that the access codes have changed -- and nothing more.
*
* **The mail carries no codes, and no door names either.** A mailbox is not a place a door code
* should end up: it is synced to phones, forwarded, and kept long after the code is rotated away.
* The door names are left out too, because some of them belong to codes only supervisors or
* administrators may read, and naming them to everybody would leak what this application
* otherwise keeps from them. So every recipient gets the same mail, and the link in it leads to
* /access-codes, which shows each of them exactly what they may see.
*
* Sent on an administrator's explicit request rather than on every save: a rotation is usually
* several edits in a row, and a corrected description or an admin-only code is no news to the
* staff.
*/
final readonly class AccessCodeNotifier
{
public function __construct(
private Mailer $mailer,
private UserRepository $users,
) {
}
public function codesUpdated(): SendReport
{
return $this->mailer->send(
array_map(
static fn (User $user): Address => new Address($user->getEmail(), $user->getName()),
$this->users->findActive(),
),
'Neue Zugangscodes bei E&P',
'email/access_codes_updated',
);
}
}
+12
View File
@@ -295,6 +295,18 @@ final readonly class AuditLogger
]);
}
/**
* The staff told that the codes changed. Counts only: the recipients are everybody, and
* which addresses failed is in the framework log, where a failure belongs.
*/
public function accessCodesAnnounced(int $sent, int $failed): void
{
$this->logger->info('Access code update announced.', [
'sent' => $sent,
'failed' => $failed,
]);
}
/**
* @return array<string, mixed>
*/
+5 -51
View File
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\Booking;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Repository\ResourceTypeSettingsRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
/**
* Tells whoever an administrator named that something was booked.
@@ -22,25 +19,14 @@ use Symfony\Component\Mime\Address;
* would otherwise never notify anybody.
*
* **Nothing here may break a booking.** It runs after the transaction has committed, so a
* failure cannot roll anything back, and a transport that is down is logged rather than thrown:
* the booking is made, and the person who made it should not be shown an error about somebody
* else's mail server.
*
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
* list, and a single bad address loses its own mail rather than everybody's.
*
* Each mail is multipart: the MyE&P layout for clients that render HTML, and a hand-written text
* part for those that do not. The text part is written rather than generated because there is no
* html-to-markdown converter installed, and three facts and a name read better as plain lines
* than as a flattened table anyway.
* failure cannot roll anything back -- and Mailer never throws, so the person who booked is not
* shown an error about somebody else's mail server.
*/
final readonly class BookingNotifier
{
public function __construct(
private MailerInterface $mailer,
private Mailer $mailer,
private ResourceTypeSettingsRepository $settings,
private LoggerInterface $logger,
private string $sender,
) {
}
@@ -80,42 +66,10 @@ final readonly class BookingNotifier
}
/**
* @param string $template the template pair's basename; both .html.twig and
* .txt.twig of it are rendered
* @param array<string, mixed> $context
*/
private function send(ResourceType $type, string $subject, string $template, array $context): void
{
$recipients = $this->settings->recipientsFor($type);
if ([] === $recipients) {
return;
}
foreach ($recipients as $recipient) {
// A fresh message per recipient: ->to() on a reused one appends rather than replaces,
// which would put the whole list back on everybody's copy.
$email = (new TemplatedEmail())
->from(new Address($this->sender, 'E&P Platz'))
->to($recipient)
->subject($subject)
->htmlTemplate($template.'.html.twig')
->textTemplate($template.'.txt.twig')
->context($context);
try {
$this->mailer->send($email);
} catch (\Throwable $e) {
// Caught per recipient, so one unreachable address does not cost the others theirs.
// \Throwable rather than TransportExceptionInterface because the templates are
// rendered inside send(): a typo in one of them would otherwise surface as a failed
// booking, which is exactly what this class promises never to do.
$this->logger->error('Booking notification could not be sent.', [
'subject' => $subject,
'recipient' => $recipient,
'exception' => $e,
]);
}
}
$this->mailer->send($this->settings->recipientsFor($type), $subject, $template, $context);
}
}