From d287cb260042ca7d93432a63d4f210e1869e4e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 28 Sep 2026 16:36:56 +0200 Subject: [PATCH] feat: notify configured recipients when something is booked --- .env | 6 + assets/images/logo.png | Bin 0 -> 7804 bytes config/packages/messenger.yaml | 8 +- config/packages/twig.yaml | 4 + config/services.yaml | 6 + migrations/Version20260928124153.php | 26 ++ .../Admin/Resource/IndexController.php | 6 + .../ResourceTypeSettings/EditController.php | 66 +++++ src/Entity/ResourceTypeSettings.php | 89 ++++++ .../Admin/ResourceTypeSettingsFormType.php | 61 ++++ .../ResourceTypeSettingsRepository.php | 60 ++++ src/Service/AuditLogger.php | 15 + src/Service/BookingManager.php | 5 + src/Service/BookingNotifier.php | 121 ++++++++ .../_modal_resource_type_settings.html.twig | 31 ++ templates/admin/resources.html.twig | 4 + templates/email/booking_created.html.twig | 26 ++ templates/email/booking_created.txt.twig | 14 + templates/email/layout.html.twig | 279 ++++++++++++++++++ templates/email/series_created.html.twig | 37 +++ templates/email/series_created.txt.twig | 13 + .../Admin/ResourceAdminControllerTest.php | 50 ++++ tests/Service/BookingManagerTest.php | 88 ++++++ 23 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 assets/images/logo.png create mode 100644 migrations/Version20260928124153.php create mode 100644 src/Controller/Admin/ResourceTypeSettings/EditController.php create mode 100644 src/Entity/ResourceTypeSettings.php create mode 100644 src/Form/Admin/ResourceTypeSettingsFormType.php create mode 100644 src/Repository/ResourceTypeSettingsRepository.php create mode 100644 src/Service/BookingNotifier.php create mode 100644 templates/admin/_modal_resource_type_settings.html.twig create mode 100644 templates/email/booking_created.html.twig create mode 100644 templates/email/booking_created.txt.twig create mode 100644 templates/email/layout.html.twig create mode 100644 templates/email/series_created.html.twig create mode 100644 templates/email/series_created.txt.twig diff --git a/.env b/.env index dc01332..78710d8 100644 --- a/.env +++ b/.env @@ -56,6 +56,12 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 MAILER_DSN=null://null ###< symfony/mailer ### +###> app/notifications ### +# Sender of every booking notification; each configured recipient gets their own mail. +# Must be an address the mail host accepts mail from -- production overrides this in .env.local. +MAILER_FROM=platz@ep-reisen.de +###< app/notifications ### + ###> app/myep-oauth2 ### # OAuth2 against myep, the EP identity provider. ephub is a confidential client using the # authorization_code grant; it reads /api/userinfo once at login and then uses its own session, diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d079550a0efb4692ddfde0afc1e11ae0fbb934fc GIT binary patch literal 7804 zcmWkzdpy(a8{V+lY;1;^VN6I7TSmxXvz*@0bVSlv&6HxvA)(nE<}4j7ue7C~)|r$j zl{u}$VqWT1sW2@`vK&IH-`;<|pYP}M-1qZ5_x&8M>%N{sT4;c-4ps*M0O$qpiPwR>AORq^qdck0#;P5(xJ*%P~W zYV-KxsHGtI|5!QZjT=11AV;Noc3h0Ypqo{|NLrt9(dfJ7xtNacKC!`T-dxlUZEwXC zLj^@Y8oSWh8c;jJ3(}Ghj_sl$6p{b5@O+p)a-F5gpxGzInoJ(nZtOOah}MF*eKB_`xeyJ>D1jn71cG|i_Q8T1>!&71J5YownYA+o?; zIqPA{51Hj&>jbuYiW=;f>DEj-ap(TUjViO6VomC!-7mulxAK@05=`k6S^6FwtnkCV z8Trtn15<&n-fkd$2iFUInsWsF20D&&fOU3zIN?v2%kM)H(w|FOKgTu=b9Xgjt3jKI z#>OMa)8yby5d>pu6KjpmI>Da_r>Pi8Npt-~*(}?~+kXc-?b5?iQ?^4S9!&Ii5Ipcr z9$~?lHy-)7f}F9{A`L|&RvoRa5mXW~OU%NFKXN074rrP~H96kHmiinW_)dWB71(W;j3+sruh!(C3$ zWtY-#+-r^3K+Z$Y*jkQK=S72RO^cDpqj%q3>(Q39!B8}N5p)>>s)$S|ydZ+UAp#^( z6i5^vvWbpl*z2-MQZ8UXmPz+_WLLZpz(QybA>bzD<73_)T~7b>8II6Px;GC&UNmk<(odazcM`#H5Zp?S z!j(`C^gmS;#1%#navq3q@Xa3{51$^z$lAZrEYe}d7md0wu=>RFdh;W>9^kW?;1GE( zM0?iimg$MDKaHz&T$n`Arh2u! z{`t5oA!nP)+SNB}#B0SOHTOU3OYuQ9#qUjyVTwKmMcJ;?*HadrgVx8F&Z^cP5PHwJ z_s~}dj+ihc3H(GMZu{6f>%Q=BCMtR;H-n(O@P}fX|Bk5l?dQ~yfsIN0L&HQoL!#vo z_nEqsSHahwC5p9IKlsh$eI?@d`eu#W)NnG84Buy5XfTH<|MlPTBxY*Qj|Beqe?T_T zR5=U=WOtomX%@oOndx4HR0E5{Gz!xR+7zz?Ushh69K_ulL5Q~`w40fgWd_E|mjC_n zjOu)cYP@1fB^a(A-CmQ^#Kt*x*>D*}-x~iq_bxyvq_`2bHVNA^#*|5B7q#qZx-U3)7xfYq#9>~s z9B6}7{7ug;GHn`h2n4&dQ{|-Uyk&Km&S%Fff4m5TGI1F(=vUONadWtTSXW)R#c`37 zW6#%gy$>SrV= zJ-Oi6AiX6ep)UR08Z*M-OK(GfzH^$ z83cwKe2aRZRo4j6w*li>V`US?s&KxIoF7ph;bypmWJu;R`QFFu8xIC@myB%F3D8-U zF3E2-xx-NAIoJ7sb|He>)Nk39Gpxr;f<;RYh}nP-qel%XC7^+inC(>O5z{+!CkR!7pU8W5UFfEmCu8B9tRz>aLYvhQY*W^>Iuur4cM!d>v#oF|jJfqU2%157 zp5*$dU|Ck~rt61??$ey>*q?5DXiK?La|&4K08`?^K`*!ks5Y}~AnsiWQy;1{s<6~< z()S;W6Zbht-R+?1miM|FTTH%*u+{oZuio2DwI|_BxD(|R?cDgb&vz?4NP$TMu`ycQ zCF}1ztPI_Py(aXYg*lQV0183*L$)n*Y$NN{xq^NO$^EHrYRc20^OEZvzoso!dWFAiTgV;L@TJe6plwojH=&)L7&-?;vAzl+O+2Gks;)Qj$6Wh_FoOjoq# zS@`el^(5fNk9LC1M5iEUBGi~}8A*-O(3fHI>8<6fJ?G{|UzlqvGKb5aVv3sxn1^gg zYBzd~&GJch%T$}z&J^8cH9?Fe!xTLmdFk2 z#dbKrc`D{E%PrinvFlO)U?S^$ZgwVzLtXx`;wPAWk%Vt~wiDPuR49*0-Vjb`vdm|w z3Di}mMD z3B%#gD~H3ibw#OY*^8@^d^1&s);@hw(1kH{2|4kOpR-x6L6VDd6v3)q;oMj!xYGUI zU)&cGP&?!}uuC+=i46=iFVmG*eBLLSyX$0$ODJ%W1CCkfItbo(6QGmHi9(vg*+W7{ zZHn&{YdeKHjtLA5C*&lHfU{aUYNIHf`Bt9>uT2huUF1FfWZ!8Y{9DRf6h zg#R12^l8YVUzb}p1em9Ld8n!3O`~ky@^Iu-f=H{z^yk06iqX9l5IclqlKf7d8(Mh9ybHDa)Udt^X3p@4dj?QU(=VQWW}DW+)Y@rJ)Y@Lt0oQ#CKzcL91UuO0SJJnd^W zV$UL{{woL?7;bJQw_WX^MBJ~&gOEgNx*?Wqx}p;GV7Bdc zc&onlkW@?kL5wuU@o*ZbX?Jz@#otIotaaI@F90V+Xg*l}9LWft{`S?hvfynTm?H=8T!pL!H>D#stn| zp-BULAPG}s2|Pk1(XTeRbYV^%|1)=V2}B~-=H>?DJYrFgxQBbz+@$N})%WibaHtxO zt}W=X+GTkd#)^pyr`|G9l!lGC{d_oZUUrwnP<2s3L2!oB{(+$J$Tj>xtWuH9U3;?nkVCbVtA<&>4S|^&5Vpv$z7{ zu8*}eQfKNh-{AR9IR^_Matvgh^bJ#E7e>vpb57hZCH%_E(r~}`kYc=Ot zi$EmZH)PLAK+4jFuDX}BfTj~V%9C==?xL*_Krz#X)GN_{W3tfz3;5SJID`B7yUjv* zI)Tw*P@TKW7b&~Y@A*z*2G-4)wFEc_%Bg!E`OA9uEI&)O;|N}WXEX0y>yDn_9aI?) zs#anDw?^=ZK|@7;BnaXtd$QYGhfGUp`3sm_djjGyg&l+Cw(9XJ2Q5F+HU5O85uCXB z?K*>M_J~mG(yE?Y>Nl9a^39qKA#nt`T=(|Qti}rz^R!EA1cxl2J_#dy=`MYriSdJm zT5mMmeHkyfW*ITASHJtuQE;*b$+||L-9WF^ONrNnwrbKfJbv+r|gYYMl15KdbnD%GI)P~|m9M4|fWN`@<`7gf^T zjMr1bkW~YUD&{F&rirWXsu9qgyiF8--;(a^5^cWJO}Ek&ExUr*S)iW|Fcp>|!m8`( zz3rvAqtA~Ka;!?a|6ay`&mP(WRk0Jiwvy?%|(WL379Ob#4P4E`9 z+t3Ptu4C~l78w`KDqWhjzI+NxJ4nQKSD4+VYoytnpyq)F^ovc;c(|OFXxUQD_twF= zeFo}c>^HaCBdgTC1@ca=Rk;)W{JaW3cp1HP_&_-*`QY_D5041Kwp!#Io6n0toS{jz zAl{*B;-yRLlUbx2BSt-HilSM?wsxGwP5A1VjpIuWr;oYmAIxmiY}OcaR-ca}V#S{E z2!hu^NJ@flwjB>T$MLw@==6RbCG%g@t4?L=W_Mgp*W41TSI^M!S2(~WVH0_sNm^@@ z43~u#;E`~653+_c>ZYwo@|y1UZObj6@%mj5@1`@Kg?{d(T02Pl+z;)C2a21wcrv7Z7w%i~*+ilmLv+#Zut_5JAsl}C@h;zDI1 z-U2ca$6GF20{viER)bkT4x02ZGza4j5D*W@8fuc(nLIu8KQr05*U37%Ue|6~`76bn zsE}oL>)Pj%VT}{k7Qw=ufi0gD-20~`>{~~6>IOmA09)-`jOl3 zlT>Q*hukFuR~s?c;5r~v1s$B)%MKm!i@-_eFG&WGl#)W7Pg%^3Q^jXp^#(N&C)ma- zfDrp_ymF$hROAv1wD6>HMi;@8&exJ$BGt%g8y4+zjwB&%r<{s?HRczU_udZCpIe)0 zR&YyQ9y|MA)bFxqlmsoDe58xJv;R%5gVeL8Tr`UDM30@jHP3F|a>3506Ka=x=^2Lc&pfKtJAZ6HOY6GyL5udM zi>B57EVm5%;Eun<<=YiRGoB~^7Dlb^9PT}K)?1MKZzFzWTXM9-3&UeHn(A1k^8(@K zGe~tU`rXAp<{+Qgv(MMHnNT%?jN&hak2aZK{V?VpEcKfx_6oB=rS$e!P}1KP{0QqM zS0$9(a_$jC)c}iPwCCE%CGBTg%wZ~zM?DvUx)KetJ3pnHBomDkY&2MCno2G9&<2Ft z-D|hA=*`$uvhC2lYsAt0lHlK;j~)!zOrpADFXD>ADw<1O&`*8Mcm-Lh$DcR}xbN^M zW3;D!1o#!DhS0Bu=56*0th_JmXf(G5KBS$zxDF3}V`5bH5U-{Ypo$rrcV0x-()(0T zalt9JCVIrHt@xNAw@Gf`R|t0`pr54*a*e#-w$HjPiy$a>w9HR#xTmUp__dR$3}0bQ z>a?|^rHS{PrED8SXr$lYcigBsFFh%6*ydd&r%+D1kFDf@F$H@?(D1^aVQMuv?qovl zwi(;OfcITCL*Ur;YMHHs=TVz$JzBCT`S1u~J7XQIR#Clcb9=7z+*>sJTd10;0S)k* z-0NY}osnY1_R)x%_yLsu zJzRG{CFK?c*q0W57?HAFw7*i4pc@qVT@sad=#(CFvPkGi4Ahwvz4;aMywj%%gxyW3 zGk*Gz?eb`$Up}kd-&auOYzkAx)1-eR5&jhOa;gSOR<6cHDoR#H3)o+I8Sfy_=a1L6 zpwxOJ-MwBxc-x{#M$77lASriLNfGmZgk=<@$XcPUhZqej$|eHv*?Q&NGBp+Li1+4rn$3f@NGe9`8CLk#&BfNnxx62|UsAd6L%Q z?~15^V02QJz*Te@jOpe& zHB~e_WB7`iJh3X6lBgzm>Ia9^1tcXcYBfazEjx(}fWt&(myxgpLQb)eeYF7+P-o{R zxfAHR$s$;qCO3P{-@x_)(_#ERHGel~>F43sIDE>2rOb&L;2XzZC*C4y%|@61Dc`dd zu5{x+f5onft}&dTYXpqk@bDk?;ykjOiz9qZ7!PdA4RIg7eE)K``A(8c3BGAZ_j@Y%+aw$QWIs@73k|Wm?G{lXu#^Vp>!Aj>?Wq$TumUq|pa9a-!8`hu#YyS9}8&fqfvTJzu_ZgkRMoUNfaczR&aQC7y zGj<)R*HoM+k-aC+Mq|o1gq`A1fUqQ@hsZG2x1D9^|c%zn&OXg|P$M~{TAfkRZ72cgptGXO^`aM0;Y_`EZU!j*-`H`H>1GN|iyd9`( z4DpF~(U=q<*zTgTzt?5n|16~va!%5uM@Tus5I*;Fs+lXre3gUXO5#7C^VsPckK@?e zdVjWPuabj4BGuQU$pC5xErHD7E7ph^;eose{tr>v13b&<8C}DeYM#7K@V*w8CLs)$ zl{d}JwV<5F!0_((>ZdzR7&FxicLIcNCc7AZkrUMXWPxHd*bm#C?$Hz+V8Rp{@jDH37f+-{fvrM zSp={CYa_$1UQq}4@_HpimB*|P^TI{;Af z3A^{wz@wXrBY(GoCX9`rmrei*jR#`@dy;JH$97!L&A5ZTFt%p6a5K^DVFPTEnki8S z>?}B&TUcQ@&R9e01)4yV!NHq5&P70&t5z@-^ymnwUE4i^!E6KOv9H3+xJ3dAZP05^ z3YIwI>OWqW?n=iwC)tGhB~In^EZ~Ub5|HyTAL>>Tbn?4RY&?Sb9rq%gkh4k?CHs|R zzmx2>2&Uris;HP^x!yyz$IVVN&tkuH+H85lCEzPhyt2iX{vlyqY;AFnA8lr8j v^YV?DA}l%cIuKjmG0XQnRba*6^#?K7aU^_t_~r)nt1=+SFLcvQUpn{y0B*lg literal 0 HcmV?d00001 diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 55f252d..06be806 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -18,7 +18,13 @@ framework: messenger.bus.default: [] routing: - Symfony\Component\Mailer\Messenger\SendEmailMessage: async + # Mail is deliberately NOT routed to async. Nothing starts a worker on the server -- + # deploy.php only stops them -- so a queued SendEmailMessage would be written to + # messenger_messages and stay there, with no error anywhere and nobody notified. + # Sending happens in the request instead; BookingNotifier catches a transport + # failure so it can never fail the booking that triggered it. Route it back here + # once a `messenger:consume async` worker actually runs. + # Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index c74c19c..bd9c105 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -2,6 +2,10 @@ twig: file_name_pattern: '*.twig' # One theme for the whole app, so it is registered globally rather than per-template. form_themes: ['forms.html.twig'] + paths: + # Mail templates embed the logo with email.image('@images/logo.png'), which needs the file + # on a Twig path -- an asset URL would be a remote image most clients block. + '%kernel.project_dir%/assets/images': images when@test: twig: diff --git a/config/services.yaml b/config/services.yaml index f7d5f5a..df5b2d6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -26,6 +26,12 @@ services: arguments: $logger: '@monolog.logger.audit' + # Booking notifications. The sender is also the To of every notification -- the actual + # recipients are Bcc'd -- so it has to be an address that may both send and receive. + App\Service\BookingNotifier: + arguments: + $sender: '%env(string:MAILER_FROM)%' + # The myep OAuth2 client. Endpoint URLs are configuration rather than constants (same as # myep-team), so dev, staging and production differ by .env alone. Empty credentials are a # valid state: the login page then says the client is not registered yet instead of bouncing diff --git a/migrations/Version20260928124153.php b/migrations/Version20260928124153.php new file mode 100644 index 0000000..15dd652 --- /dev/null +++ b/migrations/Version20260928124153.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE resource_type_settings (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, notification_recipients JSON NOT NULL, UNIQUE INDEX uniq_resource_type_settings_type (type), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE resource_type_settings'); + } +} diff --git a/src/Controller/Admin/Resource/IndexController.php b/src/Controller/Admin/Resource/IndexController.php index 06c5d82..59e1d15 100644 --- a/src/Controller/Admin/Resource/IndexController.php +++ b/src/Controller/Admin/Resource/IndexController.php @@ -7,6 +7,7 @@ namespace App\Controller\Admin\Resource; use App\Enum\ResourceType; use App\Repository\ResourceGroupRepository; use App\Repository\ResourceRepository; +use App\Repository\ResourceTypeSettingsRepository; use App\Security\Role; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -26,12 +27,16 @@ final class IndexController extends AbstractController public function __construct( private readonly ResourceRepository $resources, private readonly ResourceGroupRepository $groups, + private readonly ResourceTypeSettingsRepository $settings, ) { } #[Route('/admin/resources', name: 'app_admin_resource', methods: ['GET'])] public function index(): Response { + // Read once for the whole page rather than per section: whether a kind notifies anybody + // is only a number on a button, and it should not cost a query per kind. + $recipients = $this->settings->recipientsByType(); $sections = []; foreach (ResourceType::cases() as $type) { @@ -55,6 +60,7 @@ final class IndexController extends AbstractController $this->groups->findByType($type), ), 'ungrouped' => $ungrouped, + 'recipients' => \count($recipients[$type->value] ?? []), ]; } diff --git a/src/Controller/Admin/ResourceTypeSettings/EditController.php b/src/Controller/Admin/ResourceTypeSettings/EditController.php new file mode 100644 index 0000000..563751f --- /dev/null +++ b/src/Controller/Admin/ResourceTypeSettings/EditController.php @@ -0,0 +1,66 @@ +settings->findOrCreate($type); + + $form = $this->createForm(ResourceTypeSettingsFormType::class, $settings, [ + 'action' => $this->generateUrl('app_admin_resource_type_notifications', ['type' => $type->value]), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // Nothing configured and nothing entered: no row is written at all, so a kind + // nobody notifies about stays absent from the table. + if (null !== $settings->getId() || $settings->hasNotificationRecipients()) { + $this->em->persist($settings); + $this->em->flush(); + + $this->audit->notificationRecipientsChanged($settings); + } + + $this->addFlash('success', \sprintf('Benachrichtigungen für %s gespeichert.', $type->plural())); + + return new HxRedirectResponse($this->generateUrl('app_admin_resource')); + } + + return $this->render('admin/_modal_resource_type_settings.html.twig', [ + 'form' => $form, + 'type' => $type, + ]); + } +} diff --git a/src/Entity/ResourceTypeSettings.php b/src/Entity/ResourceTypeSettings.php new file mode 100644 index 0000000..8ee3c59 --- /dev/null +++ b/src/Entity/ResourceTypeSettings.php @@ -0,0 +1,89 @@ + + */ + #[ORM\Column(type: Types::JSON)] + #[Assert\All([ + new Assert\Email(message: '"{{ value }}" ist keine gültige E-Mail-Adresse.'), + new Assert\Length(max: 180), + ])] + private array $notificationRecipients = []; + + public function __construct(ResourceType $type) + { + $this->type = $type; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): ResourceType + { + return $this->type; + } + + /** + * @return list + */ + public function getNotificationRecipients(): array + { + return $this->notificationRecipients; + } + + /** + * @param list $recipients + */ + public function setNotificationRecipients(array $recipients): void + { + $this->notificationRecipients = array_values($recipients); + } + + public function hasNotificationRecipients(): bool + { + return [] !== $this->notificationRecipients; + } +} diff --git a/src/Form/Admin/ResourceTypeSettingsFormType.php b/src/Form/Admin/ResourceTypeSettingsFormType.php new file mode 100644 index 0000000..1a59d73 --- /dev/null +++ b/src/Form/Admin/ResourceTypeSettingsFormType.php @@ -0,0 +1,61 @@ +add('notificationRecipients', TextareaType::class, [ + 'label' => 'Benachrichtigungen an', + 'help' => 'Eine E-Mail-Adresse pro Zeile. Leer lassen, wenn niemand benachrichtigt werden soll.', + 'required' => false, + 'attr' => ['rows' => 4], + ]); + + $builder->get('notificationRecipients')->addModelTransformer(new CallbackTransformer( + /** @param list $recipients */ + static fn (array $recipients): string => implode("\n", $recipients), + static fn (?string $text): array => self::parse($text), + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ResourceTypeSettings::class, + ]); + } + + /** + * Splits on lines, commas and semicolons, then drops blanks and duplicates. + * + * Nothing is validated here -- an address that is not one has to reach the entity, or the + * Email constraint there would never see it and the typo would be silently swallowed. + * + * @return list + */ + private static function parse(?string $text): array + { + $parts = preg_split('/[\s,;]+/', (string) $text) ?: []; + + return array_values(array_unique(array_filter(array_map(trim(...), $parts), static fn (string $part): bool => '' !== $part))); + } +} diff --git a/src/Repository/ResourceTypeSettingsRepository.php b/src/Repository/ResourceTypeSettingsRepository.php new file mode 100644 index 0000000..f9c2af0 --- /dev/null +++ b/src/Repository/ResourceTypeSettingsRepository.php @@ -0,0 +1,60 @@ + + */ +class ResourceTypeSettingsRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceTypeSettings::class); + } + + public function findOneByType(ResourceType $type): ?ResourceTypeSettings + { + return $this->findOneBy(['type' => $type]); + } + + /** + * The settings for a kind, creating them unpersisted when nobody has configured it yet. + * + * Persisting is left to the caller, so merely opening the form does not write a row. + */ + public function findOrCreate(ResourceType $type): ResourceTypeSettings + { + return $this->findOneByType($type) ?? new ResourceTypeSettings($type); + } + + /** + * @return array> recipients by type value, kinds without any omitted + */ + public function recipientsByType(): array + { + $byType = []; + + foreach ($this->findAll() as $settings) { + if ($settings->hasNotificationRecipients()) { + $byType[$settings->getType()->value] = $settings->getNotificationRecipients(); + } + } + + return $byType; + } + + /** + * @return list + */ + public function recipientsFor(ResourceType $type): array + { + return $this->findOneByType($type)?->getNotificationRecipients() ?? []; + } +} diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index 6d098c5..158ccce 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -9,6 +9,7 @@ use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\ResourceGroup; +use App\Entity\ResourceTypeSettings; use App\Entity\User; use App\Enum\CodeVisibility; use App\Enum\ResourceType; @@ -218,6 +219,20 @@ final readonly class AuditLogger ]); } + /** + * The addresses themselves are recorded, unlike an access code's value: they are not + * secrets, the trail already names booking holders by email, and who was added to -- or + * quietly dropped from -- a notification list is exactly what makes this record worth + * keeping. + */ + public function notificationRecipientsChanged(ResourceTypeSettings $settings): void + { + $this->logger->info('Notification recipients changed.', [ + 'type' => $settings->getType()->value, + 'recipients' => $settings->getNotificationRecipients(), + ]); + } + public function resourceGroupCreated(ResourceGroup $group): void { $this->logger->info('Resource group created.', $this->group($group)); diff --git a/src/Service/BookingManager.php b/src/Service/BookingManager.php index a844165..c0637f7 100644 --- a/src/Service/BookingManager.php +++ b/src/Service/BookingManager.php @@ -44,6 +44,7 @@ final readonly class BookingManager private EntityManagerInterface $em, private BookingRepository $bookings, private AuditLogger $audit, + private BookingNotifier $notifier, ) { } @@ -72,6 +73,9 @@ final readonly class BookingManager // ever reaching this line. $this->audit->bookingCreated($booking); + // After the commit for the same reason, and it never throws: see BookingNotifier. + $this->notifier->bookingCreated($booking); + return $booking; } @@ -118,6 +122,7 @@ final readonly class BookingManager }); $this->audit->seriesCreated($result); + $this->notifier->seriesCreated($result); return $result; } diff --git a/src/Service/BookingNotifier.php b/src/Service/BookingNotifier.php new file mode 100644 index 0000000..aa782f2 --- /dev/null +++ b/src/Service/BookingNotifier.php @@ -0,0 +1,121 @@ +getResource(); + + $this->send( + $resource->getType(), + \sprintf('Neue Buchung: %s am %s', $resource->getLabel(), $booking->getDate()->format('d.m.Y')), + 'email/booking_created', + ['booking' => $booking], + ); + } + + /** + * One mail for a whole series, not one per occurrence. + * + * A year of weekly bookings is the same act and would otherwise arrive as fifty-two mails in + * one second -- the same reason AuditLogger records a series once rather than per day. + */ + public function seriesCreated(SeriesResult $result): void + { + if ($result->isCompletelyBlocked()) { + return; + } + + $series = $result->series; + $resource = $series->getResource(); + + $this->send( + $resource->getType(), + \sprintf('Neue Serienbuchung: %s ab %s', $resource->getLabel(), $series->getStartDate()->format('d.m.Y')), + 'email/series_created', + ['result' => $result, 'series' => $series], + ); + } + + /** + * @param string $template the template pair's basename; both .html.twig and + * .txt.twig of it are rendered + * @param array $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, + ]); + } + } + } +} diff --git a/templates/admin/_modal_resource_type_settings.html.twig b/templates/admin/_modal_resource_type_settings.html.twig new file mode 100644 index 0000000..ffe981d --- /dev/null +++ b/templates/admin/_modal_resource_type_settings.html.twig @@ -0,0 +1,31 @@ +{# Who is notified about new bookings of one kind. See admin/_modal_resource.html.twig for how it closes. #} +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Benachrichtigungen · {{ type.plural }}{% endblock %} + +{% block content %} + {{ form_start(form, { attr: { + 'hx-post': form.vars.action, + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } }) }} + {{ form_errors(form) }} + +

+ Diese Adressen erhalten eine E-Mail, sobald {{ type.plural }} gebucht werden. +

+ +
+ {{ form_row(form.notificationRecipients) }} +
+ +
+ + +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/resources.html.twig b/templates/admin/resources.html.twig index eac340b..5b4d487 100644 --- a/templates/admin/resources.html.twig +++ b/templates/admin/resources.html.twig @@ -53,6 +53,10 @@

{{ type.plural }}

+ {{ _self.opener( + path('app_admin_resource_type_notifications', { type: type.value }), + section.recipients > 0 ? 'Benachrichtigungen (' ~ section.recipients ~ ')' : 'Benachrichtigungen' + ) }} {{ _self.opener(path('app_admin_resource_group_new', { type: type.value }), type.groupLabel ~ ' hinzufügen') }} {{ _self.opener(path('app_admin_resource_new', { type: type.value }), type.label ~ ' hinzufügen', 'primary') }}
diff --git a/templates/email/booking_created.html.twig b/templates/email/booking_created.html.twig new file mode 100644 index 0000000..81e5ac2 --- /dev/null +++ b/templates/email/booking_created.html.twig @@ -0,0 +1,26 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} + {% set resource = booking.resource %} +

Neue Buchung

+ +

+ {{ resource.type.label }} {{ resource.label }} wurde gebucht. +

+ +

+ Datum: {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} +
+ Zeit: {{ booking.timeRange }} + {% if resource.group %} +
+ {{ resource.type.groupLabel }}: {{ resource.group.label }} + {% endif %} +

+ +

+ Gebucht von {{ booking.user.name }} +
+ {{ booking.user.email }} +

+{% endblock %} diff --git a/templates/email/booking_created.txt.twig b/templates/email/booking_created.txt.twig new file mode 100644 index 0000000..ecf3fac --- /dev/null +++ b/templates/email/booking_created.txt.twig @@ -0,0 +1,14 @@ +{# + Plain text on purpose: a notification is three facts and a name, and a text part renders the + same everywhere without a stylesheet anybody has to maintain. +#} +{% set resource = booking.resource -%} +{{ resource.type.label }} {{ resource.label }} wurde gebucht. + +Datum: {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} +Zeit: {{ booking.timeRange }} +{% if resource.group %}{{ resource.type.groupLabel }}:{{ ' ' }}{{ resource.group.label }} +{% endif %}Gebucht von: {{ booking.user.name }} ({{ booking.user.email }}) + +-- +E&P Platz diff --git a/templates/email/layout.html.twig b/templates/email/layout.html.twig new file mode 100644 index 0000000..43c5160 --- /dev/null +++ b/templates/email/layout.html.twig @@ -0,0 +1,279 @@ +{# + The MyE&P mail layout, adopted so a mail from Platz looks like every other mail from E&P. + Kept as the exported MJML output it is over there -- table scaffolding, Outlook conditionals + and all -- so a fix made on either side ports across as a diff rather than a rewrite. Edit the + MJML source in myep and re-export rather than hand-patching the tables here. + + Blocks: `body`. Type scale comes from the + + + + + + + + + + + + + +
E&P Platz
+
+ +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ Logo E&P +
+
+
+
+ +
+
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{% block body %}{% endblock %} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
E&P Reisen und Events GmbH
Aachener Str. 326-328
50933 Köln
+
+
+ +
+ + + + + + +
+ +
+
+ +
+
+ +
+ + + diff --git a/templates/email/series_created.html.twig b/templates/email/series_created.html.twig new file mode 100644 index 0000000..746737a --- /dev/null +++ b/templates/email/series_created.html.twig @@ -0,0 +1,37 @@ +{% extends 'email/layout.html.twig' %} + +{# One mail for the whole series -- see BookingNotifier for why it is not one per occurrence. #} +{% block body %} + {% set resource = series.resource %} +

Neue Serienbuchung

+ +

+ {{ resource.type.label }} {{ resource.label }} wurde als Serie gebucht. +

+ +

+ Zeitraum: {{ series.startDate|format_datetime(pattern: 'd. MMMM y') }} + bis {{ series.endDate|format_datetime(pattern: 'd. MMMM y') }} +
+ Rhythmus: {{ series.describe }} +
+ Termine: {{ result.createdCount }} + {% if resource.group %} +
+ {{ resource.type.groupLabel }}: {{ resource.group.label }} + {% endif %} +

+ + {% if result.skippedCount > 0 %} +

+ {{ result.skippedCount }} Tage waren bereits vergeben und wurden übersprungen: + {{ result.skippedDates|join(', ') }} +

+ {% endif %} + +

+ Gebucht von {{ series.user.name }} +
+ {{ series.user.email }} +

+{% endblock %} diff --git a/templates/email/series_created.txt.twig b/templates/email/series_created.txt.twig new file mode 100644 index 0000000..ede3e96 --- /dev/null +++ b/templates/email/series_created.txt.twig @@ -0,0 +1,13 @@ +{# One mail for the whole series -- see BookingNotifier for why it is not one per occurrence. #} +{% set resource = series.resource -%} +{{ resource.type.label }} {{ resource.label }} wurde als Serie gebucht. + +Zeitraum: {{ series.startDate|format_datetime(pattern: 'd. MMMM y') }} bis {{ series.endDate|format_datetime(pattern: 'd. MMMM y') }} +Rhythmus: {{ series.describe }} +Termine: {{ result.createdCount }} +{% if result.skippedCount > 0 %}Belegt: {{ result.skippedCount }} Tage waren bereits vergeben ({{ result.skippedDates|join(', ') }}) +{% endif %}{% if resource.group %}{{ resource.type.groupLabel }}:{{ ' ' }}{{ resource.group.label }} +{% endif %}Gebucht von: {{ series.user.name }} ({{ series.user.email }}) + +-- +E&P Platz diff --git a/tests/Controller/Admin/ResourceAdminControllerTest.php b/tests/Controller/Admin/ResourceAdminControllerTest.php index 70c80e7..ed90826 100644 --- a/tests/Controller/Admin/ResourceAdminControllerTest.php +++ b/tests/Controller/Admin/ResourceAdminControllerTest.php @@ -8,6 +8,7 @@ use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\ResourceGroup; +use App\Entity\ResourceTypeSettings; use App\Entity\User; use App\Enum\RecurrenceRule; use App\Enum\ResourceType; @@ -48,6 +49,9 @@ class ResourceAdminControllerTest extends WebTestCase $this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->client->request('GET', '/admin/resource-types/desk/notifications', server: ['HTTP_HX-Request' => 'true']); + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); } public function testTheListShowsRoomsAndRetiredResources(): void @@ -293,6 +297,52 @@ class ResourceAdminControllerTest extends WebTestCase * * @param array $fields */ + public function testNotificationRecipientsAreSavedForAKind(): void + { + $this->client->loginUser($this->admin()); + + $response = $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', [ + // Pasted from a mail client, separators and all: the form is meant to cope. + 'notificationRecipients' => "empfang@ep-reisen.de, hausmeister@ep-reisen.de\n\n", + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $settings = $this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK]); + self::assertNotNull($settings); + self::assertSame(['empfang@ep-reisen.de', 'hausmeister@ep-reisen.de'], $settings->getNotificationRecipients()); + } + + public function testAnAddressThatIsNotOneIsRefused(): void + { + $this->client->loginUser($this->admin()); + + $response = $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', [ + 'notificationRecipients' => 'empfang@ep-reisen.de kein-komma-vergessen', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertNull($this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK])); + } + + /** + * Emptying the field is how notifications are switched off again. + */ + public function testRecipientsCanBeClearedAgain(): void + { + $settings = new ResourceTypeSettings(ResourceType::DESK); + $settings->setNotificationRecipients(['empfang@ep-reisen.de']); + $this->em->persist($settings); + $this->em->flush(); + $this->client->loginUser($this->admin()); + + $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', ['notificationRecipients' => '']); + + $this->em->clear(); + $fresh = $this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK]); + self::assertNotNull($fresh); + self::assertSame([], $fresh->getNotificationRecipients()); + } + private function submit(string $uri, string $formName, array $fields): Response { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); diff --git a/tests/Service/BookingManagerTest.php b/tests/Service/BookingManagerTest.php index b99666f..7e8d7c8 100644 --- a/tests/Service/BookingManagerTest.php +++ b/tests/Service/BookingManagerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Service; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; +use App\Entity\ResourceTypeSettings; use App\Entity\User; use App\Enum\RecurrenceRule; use App\Enum\ResourceType; @@ -15,6 +16,7 @@ use App\Model\TimeRange; use App\Service\BookingManager; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Bundle\FrameworkBundle\Test\MailerAssertionsTrait; /** * The double-booking guard now lives here rather than in a unique index, so this is where it is @@ -24,6 +26,8 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; */ class BookingManagerTest extends KernelTestCase { + use MailerAssertionsTrait; + private EntityManagerInterface $em; private BookingManager $manager; @@ -292,6 +296,89 @@ class BookingManagerTest extends KernelTestCase self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d')); } + public function testABookingNotifiesTheRecipientsOfItsType(): void + { + $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); + + $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + // One mail each, addressed to them alone: no recipient learns who else is on the list. + self::assertEmailCount(2); + $emails = self::getMailerMessages(); + self::assertSame( + ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de'], + array_map(static fn ($email): string => $email->getTo()[0]->getAddress(), $emails), + ); + + foreach ($emails as $email) { + self::assertCount(1, $email->getTo()); + self::assertSame([], $email->getBcc()); + self::assertSame('Neue Buchung: D1 am 01.10.2026', $email->getSubject()); + self::assertStringContainsString('D1', $email->getTextBody()); + // Both parts render: the MyE&P layout, and the text fallback beside it. + self::assertStringContainsString('D1', $email->getHtmlBody()); + self::assertStringContainsString('Neue Buchung', $email->getHtmlBody()); + // The logo resolved through the @images Twig path and was embedded, not hotlinked. + self::assertStringContainsString('cid:', $email->getHtmlBody()); + } + } + + public function testAKindWithNoRecipientsNotifiesNobody(): void + { + $this->recipientsFor(ResourceType::PARKING, ['empfang@ep-reisen.de']); + + $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date()); + + self::assertEmailCount(0); + } + + /** + * One mail per recipient for the series rather than one per occurrence -- four Mondays and two + * recipients make two mails, and a yearly series would otherwise be a hundred and four. + */ + public function testASeriesSendsOneMailPerRecipientNotPerOccurrence(): void + { + $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); + + $result = $this->manager->bookSeries($this->weeklySeries()); + + self::assertSame(4, $result->createdCount()); + self::assertEmailCount(2); + self::assertStringContainsString('4', (string) self::getMailerMessage()?->getTextBody()); + } + + public function testASeriesThatBookedNothingNotifiesNobody(): void + { + $this->recipientsFor(ResourceType::DESK, ['empfang@ep-reisen.de']); + $desk = $this->resource('D1'); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-05')); + + $result = $this->manager->bookSeries(new BookingSeries( + $this->user('a@ep-reisen.de'), + $desk, + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-09'), + TimeRange::fromMinutes(540, 720), + )); + + self::assertTrue($result->isCompletelyBlocked()); + // The one booking above is the only mail-worthy event, and it happened before recipients + // could matter for the series -- so exactly one mail, for that booking. + self::assertEmailCount(1); + } + + /** + * @param list $recipients + */ + private function recipientsFor(ResourceType $type, array $recipients): void + { + $settings = new ResourceTypeSettings($type); + $settings->setNotificationRecipients($recipients); + $this->em->persist($settings); + $this->em->flush(); + } + private function weeklySeries(): BookingSeries { return new BookingSeries( @@ -334,6 +421,7 @@ class BookingManagerTest extends KernelTestCase $connection->executeStatement('DELETE FROM booking'); $connection->executeStatement('DELETE FROM booking_series'); $connection->executeStatement('DELETE FROM resource'); + $connection->executeStatement('DELETE FROM resource_type_settings'); $connection->executeStatement('DELETE FROM `user`'); $this->em->clear(); }