From 8314119d4a3a6cef662ca7c2c18b85a9e90ca72d Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Mon, 12 Oct 2026 11:52:05 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat(SlotExchangeNotifier):=20e?= =?UTF-8?q?-mails=20de=20demande=20et=20de=20r=C3=A9ponse=20d'=C3=A9change?= =?UTF-8?q?=20de=20cr=C3=A9neau,=20sans=20le=20motif=20du=20demandeur=20(#?= =?UTF-8?q?455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/NoSlotExchangeNotifier.php | 19 +++ src/Planning/Service/SlotExchangeNotifier.php | 87 +++++++++++ .../Service/SlotExchangeNotifierInterface.php | 17 +++ templates/mail/exchange-decided.html.php | 26 ++++ templates/mail/exchange-decided.txt.php | 19 +++ templates/mail/exchange-requested.html.php | 26 ++++ templates/mail/exchange-requested.txt.php | 18 +++ tests/Mail/MailRendererTest.php | 15 ++ .../Service/SlotExchangeNotifierTest.php | 141 ++++++++++++++++++ 9 files changed, 368 insertions(+) create mode 100644 src/Planning/Service/NoSlotExchangeNotifier.php create mode 100644 src/Planning/Service/SlotExchangeNotifier.php create mode 100644 src/Planning/Service/SlotExchangeNotifierInterface.php create mode 100644 templates/mail/exchange-decided.html.php create mode 100644 templates/mail/exchange-decided.txt.php create mode 100644 templates/mail/exchange-requested.html.php create mode 100644 templates/mail/exchange-requested.txt.php create mode 100644 tests/Planning/Service/SlotExchangeNotifierTest.php diff --git a/src/Planning/Service/NoSlotExchangeNotifier.php b/src/Planning/Service/NoSlotExchangeNotifier.php new file mode 100644 index 00000000..8a445b3b --- /dev/null +++ b/src/Planning/Service/NoSlotExchangeNotifier.php @@ -0,0 +1,19 @@ +afterResponse->defer(fn () => $this->notify($exception, true)); + } + + public function decided(SlotException $exception): void + { + $this->afterResponse->defer(fn () => $this->notify($exception, false)); + } + + private function notify(SlotException $exception, bool $isRequest): void + { + try { + $slot = $this->slots->findById($exception->recurringSlotId()); + $holder = $slot === null ? null : $this->groups->findById($slot->groupId()); + $requester = $this->groups->findById($exception->requester()->groupId()); + if ($slot === null || $holder === null || $requester === null) { + return; + } + + $accepted = $exception->status() === SlotExceptionStatus::Acceptee; + if ($isRequest) { + $to = $holder->contactEmail(); + $subject = "demande d'échange de créneau de " . HeaderText::oneLine($requester->name()); + } else { + $user = $this->users->findById($exception->requester()->userId()); + $to = $user !== null && $user->isActive() ? $user->email() : ''; + $subject = 'échange de créneau ' . ($accepted ? 'accepté' : 'refusé') . ' par ' . HeaderText::oneLine($holder->name()); + } + if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) { + $this->logger->warning("Notification d'échange de créneau : adresse invalide", ['exchange' => $exception->id()]); + + return; + } + + $when = FrenchDate::long($exception->occurrenceDate()); + $range = substr($exception->range()?->start() ?? $slot->startTime(), 0, 5) . ' – ' . substr($exception->range()?->end() ?? $slot->endTime(), 0, 5); + $this->mailbox->send($this->mailbox->compose($to, $subject, $isRequest ? 'exchange-requested' : 'exchange-decided', [ + 'requesterGroup' => $requester->name(), + 'holderGroup' => $holder->name(), + 'accepted' => $accepted, + 'when' => $when, + 'range' => $range, + 'link' => $this->mailbox->url('/'), + 'preheader' => $when . ', ' . $range, + ])); + } catch (\Throwable $e) { + // Ni adresse ni contenu dans le journal : seulement des identifiants. + $this->logger->error("Notification d'échange de créneau : échec", ['exception' => $e::class, 'exchange' => $exception->id()]); + } + } +} diff --git a/src/Planning/Service/SlotExchangeNotifierInterface.php b/src/Planning/Service/SlotExchangeNotifierInterface.php new file mode 100644 index 00000000..63fcfe59 --- /dev/null +++ b/src/Planning/Service/SlotExchangeNotifierInterface.php @@ -0,0 +1,17 @@ + +

+

Bonjour,

+

Le groupe a la demande de :

+


+ + + + +
+ Ouvrir le planning +
+

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :

+

diff --git a/templates/mail/exchange-decided.txt.php b/templates/mail/exchange-decided.txt.php new file mode 100644 index 00000000..aad89046 --- /dev/null +++ b/templates/mail/exchange-decided.txt.php @@ -0,0 +1,19 @@ + +Bonjour, + +Le groupe a la demande de : +, + + +Ouvrir le planning : + + diff --git a/templates/mail/exchange-requested.html.php b/templates/mail/exchange-requested.html.php new file mode 100644 index 00000000..716c779a --- /dev/null +++ b/templates/mail/exchange-requested.html.php @@ -0,0 +1,26 @@ + +

Une demande d'échange de créneau

+

Bonjour,

+

Le groupe demande à échanger le créneau de :

+


+

Connectez-vous à l'appli : la demande est dans « Demandes de créneau », à l'onglet « Reçues ». Vous pouvez l'accepter ou la refuser.

+ + + + +
+ Voir la demande +
+

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :

+

diff --git a/templates/mail/exchange-requested.txt.php b/templates/mail/exchange-requested.txt.php new file mode 100644 index 00000000..80435159 --- /dev/null +++ b/templates/mail/exchange-requested.txt.php @@ -0,0 +1,18 @@ + +Bonjour, + +Le groupe demande à échanger le créneau de : +, + + +Connectez-vous à l'appli : la demande est dans « Demandes de créneau », à l'onglet « Reçues ». Vous pouvez l'accepter ou la refuser : + + diff --git a/tests/Mail/MailRendererTest.php b/tests/Mail/MailRendererTest.php index a054b7f9..4fcdb7cf 100644 --- a/tests/Mail/MailRendererTest.php +++ b/tests/Mail/MailRendererTest.php @@ -35,6 +35,8 @@ public function testEveryMailIsAFullHtmlDocumentWithTheBrandAndNoExternalResourc ['booking-pending', ['groupName' => 'Alpha', 'when' => 'mercredi 7 octobre 2026', 'range' => '09:00 – 14:00', 'link' => self::LINK]], ['booking-decided', ['groupName' => 'Alpha', 'when' => 'mercredi 7 octobre 2026', 'range' => '09:00 – 14:00', 'accepted' => true, 'note' => null, 'link' => self::LINK]], ['announcement', ['link' => self::LINK, 'forgotLink' => self::LINK]], + ['exchange-requested', ['requesterGroup' => 'Office', 'holderGroup' => 'Depece', 'when' => 'mercredi 7 octobre 2026', 'range' => '19:00 – 20:30', 'link' => self::LINK]], + ['exchange-decided', ['requesterGroup' => 'Office', 'holderGroup' => 'Depece', 'accepted' => true, 'when' => 'mercredi 7 octobre 2026', 'range' => '19:00 – 20:30', 'link' => self::LINK]], ] as [$template, $data]) { $html = $this->renderer->render($template, $data)['html']; @@ -120,6 +122,19 @@ public function testAnnouncementEscapesTheLinks(): void self::assertStringNotContainsString(''; + foreach (['exchange-requested', 'exchange-decided'] as $template) { + $html = $this->renderer->render($template, ['requesterGroup' => $evil, 'holderGroup' => $evil, 'accepted' => false, 'when' => 'jour', 'range' => 'plage', 'link' => 'https://x.test/">'])['html']; + + self::assertStringNotContainsString('