diff --git a/.claude/architecture.md b/.claude/architecture.md index e58c78e8..ebb53035 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -289,7 +289,7 @@ Navigation entre pages : rendu serveur classique (`PageController`, GET, templat ## Réservations libres : notifications et écran de validation (#263, partie 3a) -- **E-mails** (`BookingNotifier`, port `BookingNotifierInterface`, objet nul `NoBookingNotifier`, appelés par `FreeSlotBookingService` **après** création et **après** décision effective) : à **tous les administrateurs actifs** « Réservation à valider », et à **la personne qui a réservé** « Réservation validée / refusée » avec le **motif de refus** s'il y en a un. Gabarits `templates/mail/booking-pending.*` et `booking-decided.*` (HTML et texte, tout échappé). **Non désactivables** : aucune préférence consultée (règle : pas de désabonnement général). Contenu minimal : groupe, jour (`FrenchDate::long`, indépendant de la locale), plage, lien ; **jamais d'adresse ni le motif du demandeur**. Envoyés **après la réponse** (`AfterResponseInterface`), jamais bloquants : un échec est journalisé avec des identifiants seulement, jamais une adresse. +- **E-mails** (dossier `Planning/Notification` : `BookingNotifier`, port `BookingNotifierInterface`, objet nul `NoBookingNotifier`, appelés par `FreeSlotBookingService` **après** création et **après** décision effective) : à **tous les administrateurs actifs** « Réservation à valider », et à **la personne qui a réservé** « Réservation validée / refusée » avec le **motif de refus** s'il y en a un. Gabarits `templates/mail/booking-pending.*` et `booking-decided.*` (HTML et texte, tout échappé). **Non désactivables** : aucune préférence consultée (règle : pas de désabonnement général). Contenu minimal : groupe, jour (`FrenchDate::long`, indépendant de la locale), plage, lien ; **jamais d'adresse ni le motif du demandeur**. Envoyés **après la réponse** (`AfterResponseInterface`), jamais bloquants : un échec est journalisé avec des identifiants seulement, jamais une adresse. - **Page admin `/admin/bookings`** (`AdminBookingPageController`, `AdminBookingsView`, onglet « Réservations » des pages admin) : une carte par réservation à valider. **``** (Web Component rendu par le serveur) ne parle jamais à l'API : il émet `booking:decide { id, decision, note }` ; `planning/booking/admin-bookings.js` appelle `POST /api/admin/bookings/{id}/approve|refuse` (`apiFetch`, CSRF), retire la carte, et si un autre administrateur a déjà tranché (409 « déjà traitée ») retire la carte avec un message clair. Logique pure (requête, messages, décompte) dans `planning/booking/booking-review.js`, testée sans DOM ; l'identifiant doit être strictement numérique avant de toucher l'URL. - **Pastille « à valider »** sur le lien « Créneaux » du menu administrateur (`planning/booking/bookings-badge.js`, même patron que la pastille des messages : chargée à l'ouverture, rafraîchie à la minute tant que l'onglet est visible, absente pour les non-administrateurs). Pas de compteur dans tous les menus : il ne se justifie pas. @@ -298,7 +298,9 @@ Navigation entre pages : rendu serveur classique (`PageController`, GET, templat - **Plan** : `BookingPlanner::planFor` (appartenance au groupe vérifiée contre la personne, même refus pour interdit et inexistant ; règles de `FreeSlotBookingPolicy`) puis `plan` (lecture seule, rien n'est créé) : pour « le mercredi de 9h à 19h » alors qu'un autre groupe a 18h30–22h45, le plan donne les **parties libres à réserver** (`09:00–18:30`) et les **recouvrements** (`BookingPlan`, `PlanConflict` : `fixed` avec le créneau visé, le groupe titulaire et la plage chevauchée, sur laquelle une demande d'échange partielle est possible ; `booking` pour une réservation en attente ou validée, rien à demander). Un créneau fixe **du groupe lui-même** est signalé `own` (rien à lui demander, mais il n'est pas réservable). - **Calcul des plages** : fonctions pures de `TimeRange` (`intersect`, `subtract` : plages dans le désordre, qui se chevauchent ou se touchent), testées sans base. - **API** : `GET /api/bookings/plan?groupId=&bookingDate=&startTime=&endTime=` → `{ plan: { fullyFree, freeParts: [{startTime, endTime}], conflicts: [{kind, slotId, groupName, own, startTime, endTime}] } }` ; `POST /api/availability` (`recurringSlotId`, `groupId`, `occurrenceDate`, `startTime?`, `endTime?`, `reason?`) crée une **demande d'échange** via `AvailabilityService::createRequest` (201, `{ exception }` avec sa plage) ; identifiants mal formés refusés comme un accès interdit, 422 par champ, 409 si la date est déjà demandée sur ce créneau. Les noms de groupes sont déjà publics dans le planning. -- **Réservation et demande sont indépendantes** : si la demande est refusée, la partie libre reste réservée et peut être annulée. Notification du groupe titulaire : pas encore (la demande se voit sur son tableau de bord comme aujourd'hui). +- **Réservation et demande sont indépendantes** : si la demande est refusée, la partie libre reste réservée et peut être annulée. Notification du groupe titulaire : voir « Notifications d'échange » ci-dessous (#455). + +- **Notifications d'échange (#455)** : `SlotExchangeNotifier` (interface + objet nul `NoSlotExchangeNotifier`, injecté dans `AvailabilityService`). À la création d'une demande, e-mail `exchange-requested` à l'**adresse de contact du groupe titulaire** (comme les conversations entre groupes) ; à la réponse, e-mail `exchange-decided` à la personne qui avait demandé (compte actif). Envoyés après la réponse HTTP, jamais bloquants, échec journalisé avec des identifiants seulement, pas de désabonnement général. Le motif saisi par le groupe demandeur n'est jamais repris dans un e-mail (texte libre d'un groupe vers un autre). ## Réservations libres : la page « Réserver le local » (#263, partie 3b-2) diff --git a/config/services.php b/config/services.php index eb82dc4d..05a89354 100644 --- a/config/services.php +++ b/config/services.php @@ -164,9 +164,11 @@ use App\Messaging\Service\ConversationAccess; use App\Messaging\Service\ConversationGuestService; use App\Messaging\Service\ConversationMuteService; -use App\Planning\Service\BookingNotifier; +use App\Planning\Notification\BookingNotifier; use App\Planning\Service\BookingPlanner; -use App\Planning\Service\BookingNotifierInterface; +use App\Planning\Notification\BookingNotifierInterface; +use App\Planning\Notification\SlotExchangeNotifier; +use App\Planning\Notification\SlotExchangeNotifierInterface; use App\Planning\Service\FreeSlotBookingPolicy; use App\Planning\Service\FreeSlotBookingService; use App\Messaging\Service\Mention\ConversationMentionService; @@ -344,6 +346,17 @@ $c->get(SlotExceptionRepositoryInterface::class), $c->get(GroupRepositoryInterface::class), $c->get(RecurringSlotRepositoryInterface::class), + $c->get(ClockInterface::class), + $c->get(SlotExchangeNotifierInterface::class), + )); + + $container->set(SlotExchangeNotifierInterface::class, fn ($c) => new SlotExchangeNotifier( + $c->get(Mailbox::class), + $c->get(GroupRepositoryInterface::class), + $c->get(RecurringSlotRepositoryInterface::class), + $c->get(UserRepositoryInterface::class), + $c->get(AfterResponseInterface::class), + $c->get(LoggerInterface::class), )); $container->set(FreeSlotBookingRepositoryInterface::class, fn ($c) => new MysqlFreeSlotBookingRepository($c->get(PDO::class))); diff --git a/src/Planning/Service/BookingNotifier.php b/src/Planning/Notification/BookingNotifier.php similarity index 97% rename from src/Planning/Service/BookingNotifier.php rename to src/Planning/Notification/BookingNotifier.php index 9450eb65..a8084b46 100644 --- a/src/Planning/Service/BookingNotifier.php +++ b/src/Planning/Notification/BookingNotifier.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Planning\Service; +namespace App\Planning\Notification; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -13,7 +13,7 @@ use App\Mail\Mailbox; use App\Group\Repository\GroupRepositoryInterface; use App\Account\Repository\UserRepositoryInterface; -use App\Planning\Service\BookingNotifierInterface; +use App\Planning\Notification\BookingNotifierInterface; use App\Support\FrenchDate; use App\Support\HeaderText; use Symfony\Component\Mime\Email; diff --git a/src/Planning/Service/BookingNotifierInterface.php b/src/Planning/Notification/BookingNotifierInterface.php similarity index 93% rename from src/Planning/Service/BookingNotifierInterface.php rename to src/Planning/Notification/BookingNotifierInterface.php index 454aa397..7490968f 100644 --- a/src/Planning/Service/BookingNotifierInterface.php +++ b/src/Planning/Notification/BookingNotifierInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Planning\Service; +namespace App\Planning\Notification; use App\Planning\Entity\FreeSlotBooking; diff --git a/src/Planning/Service/NoBookingNotifier.php b/src/Planning/Notification/NoBookingNotifier.php similarity index 80% rename from src/Planning/Service/NoBookingNotifier.php rename to src/Planning/Notification/NoBookingNotifier.php index 67b72d03..408d6b9f 100644 --- a/src/Planning/Service/NoBookingNotifier.php +++ b/src/Planning/Notification/NoBookingNotifier.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace App\Planning\Service; +namespace App\Planning\Notification; use App\Planning\Entity\FreeSlotBooking; -use App\Planning\Service\BookingNotifierInterface; +use App\Planning\Notification\BookingNotifierInterface; /** Objet nul : scripts et tests qui n'envoient aucune notification. */ final class NoBookingNotifier implements BookingNotifierInterface diff --git a/src/Planning/Notification/NoSlotExchangeNotifier.php b/src/Planning/Notification/NoSlotExchangeNotifier.php new file mode 100644 index 00000000..c89f6929 --- /dev/null +++ b/src/Planning/Notification/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/Notification/SlotExchangeNotifierInterface.php b/src/Planning/Notification/SlotExchangeNotifierInterface.php new file mode 100644 index 00000000..f0d613d4 --- /dev/null +++ b/src/Planning/Notification/SlotExchangeNotifierInterface.php @@ -0,0 +1,17 @@ +slotExceptionRepository->findById($exceptionId); \assert($responded !== null); + $this->notifier->decided($responded); return $responded; } @@ -122,7 +124,7 @@ public function createRequest( $this->assertValidRequest($slot, $occurrenceDate, $reason, $startTime, $endTime); - return $this->slotExceptionRepository->createRequest( + $request = $this->slotExceptionRepository->createRequest( $recurringSlotId, $occurrenceDate, $requestedByGroupId, @@ -131,20 +133,14 @@ public function createRequest( $startTime === null ? null : QuarterHour::normalise($startTime), $endTime === null ? null : QuarterHour::normalise($endTime), ); + $this->notifier->requested($request); + + return $request; } public function updateRequest(int $exceptionId, \DateTimeImmutable $occurrenceDate, ?string $reason, int $userId): SlotException { - $exception = $this->slotExceptionRepository->findById($exceptionId); - if ($exception === null) { - throw $this->accessDenied(); - } - - // IDOR : seul le groupe DEMANDEUR (A), déduit de l'exception en base - // et jamais d'un paramètre client, peut modifier sa propre demande. - if (!$this->groupRepository->isMember($exception->requester()->groupId(), $userId)) { - throw $this->accessDenied(); - } + $exception = $this->ownRequest($exceptionId, $userId); $slot = $this->recurringSlotRepository->findById($exception->recurringSlotId()); \assert($slot !== null); @@ -163,18 +159,25 @@ public function updateRequest(int $exceptionId, \DateTimeImmutable $occurrenceDa public function cancelRequest(int $exceptionId, int $userId): void { - $exception = $this->slotExceptionRepository->findById($exceptionId); - if ($exception === null) { - throw $this->accessDenied(); + $this->ownRequest($exceptionId, $userId); + + if (!$this->slotExceptionRepository->delete($exceptionId)) { + throw new RequestAlreadyRespondedException('Cette demande a déjà été traitée.'); } + } - if (!$this->groupRepository->isMember($exception->requester()->groupId(), $userId)) { + /** + * IDOR : seul le groupe DEMANDEUR, déduit de l'exception en base et jamais d'un paramètre client, agit sur sa propre demande + * (la modifier, l'annuler). Demande inconnue ou d'un autre groupe : même refus. + */ + private function ownRequest(int $exceptionId, int $userId): SlotException + { + $exception = $this->slotExceptionRepository->findById($exceptionId); + if ($exception === null || !$this->groupRepository->isMember($exception->requester()->groupId(), $userId)) { throw $this->accessDenied(); } - if (!$this->slotExceptionRepository->delete($exceptionId)) { - throw new RequestAlreadyRespondedException('Cette demande a déjà été traitée.'); - } + return $exception; } /** @@ -184,7 +187,7 @@ public function cancelRequest(int $exceptionId, int $userId): void */ private function assertValidRequest(RecurringSlot $slot, \DateTimeImmutable $occurrenceDate, ?string $reason, ?string $startTime = null, ?string $endTime = null): void { - $errors = $this->rangeErrors($slot, $startTime, $endTime); + $errors = SlotRequestRange::errors($slot, $startTime, $endTime); if ((int) $occurrenceDate->format('N') - 1 !== $slot->weekday()->value) { $errors['occurrenceDate'] = 'La date ne correspond pas au jour de ce créneau.'; } elseif ($occurrenceDate < $this->clock->now()->setTime(0, 0)) { @@ -199,42 +202,6 @@ private function assertValidRequest(RecurringSlot $slot, \DateTimeImmutable $occ } } - /** - * Plage partielle (#263) : les deux bornes ensemble, sur le quart d'heure, début avant fin, comprise dans le créneau du titulaire - * (le reste du créneau reste au titulaire). Sans borne, la demande vise tout le créneau. - * - * @return array erreurs par champ - */ - private function rangeErrors(RecurringSlot $slot, ?string $startTime, ?string $endTime): array - { - if ($startTime === null && $endTime === null) { - return []; - } - if ($startTime === null || $endTime === null) { - return ['startTime' => 'Indiquez l’heure de début et l’heure de fin, ou aucune des deux.']; - } - - $errors = []; - foreach (['startTime' => $startTime, 'endTime' => $endTime] as $field => $time) { - if (!QuarterHour::isAligned($time)) { - $errors[$field] = 'L’heure doit tomber sur un quart d’heure (par exemple 18:30).'; - } - } - if ($errors !== []) { - return $errors; - } - - [$start, $end] = [QuarterHour::normalise($startTime), QuarterHour::normalise($endTime)]; - if ($end <= $start) { - return ['endTime' => 'La fin doit être après le début.']; - } - if (!(new TimeRange($start, $end))->isWithin(new TimeRange($slot->startTime(), $slot->endTime()))) { - return ['startTime' => 'La plage doit rester dans le créneau du groupe titulaire.']; - } - - return []; - } - /** * Réponse unique pour une demande interdite ET pour une demande inexistante : * l'existence d'un identifiant ne doit pas se déduire de la réponse (IDOR, #118). diff --git a/src/Planning/Service/FreeSlotBookingService.php b/src/Planning/Service/FreeSlotBookingService.php index 08b239fe..2213ecc9 100644 --- a/src/Planning/Service/FreeSlotBookingService.php +++ b/src/Planning/Service/FreeSlotBookingService.php @@ -13,7 +13,8 @@ use App\Group\Repository\GroupRepositoryInterface; use App\Planning\Repository\RecurringSlotRepositoryInterface; use App\Security\Exception\AccessDeniedException; -use App\Planning\Service\BookingNotifierInterface; +use App\Planning\Notification\BookingNotifierInterface; +use App\Planning\Notification\NoBookingNotifier; use App\Planning\Exception\AvailabilityValidationException; use App\Planning\Exception\FreeSlotBookingConflictException; use App\Planning\Exception\RequestAlreadyRespondedException; diff --git a/src/Planning/Service/SlotRequestRange.php b/src/Planning/Service/SlotRequestRange.php new file mode 100644 index 00000000..61e96d15 --- /dev/null +++ b/src/Planning/Service/SlotRequestRange.php @@ -0,0 +1,49 @@ + erreurs par champ + */ + public static function errors(RecurringSlot $slot, ?string $startTime, ?string $endTime): array + { + if ($startTime === null && $endTime === null) { + return []; + } + if ($startTime === null || $endTime === null) { + return ['startTime' => 'Indiquez l’heure de début et l’heure de fin, ou aucune des deux.']; + } + + $errors = []; + foreach (['startTime' => $startTime, 'endTime' => $endTime] as $field => $time) { + if (!QuarterHour::isAligned($time)) { + $errors[$field] = 'L’heure doit tomber sur un quart d’heure (par exemple 18:30).'; + } + } + if ($errors !== []) { + return $errors; + } + + [$start, $end] = [QuarterHour::normalise($startTime), QuarterHour::normalise($endTime)]; + if ($end <= $start) { + return ['endTime' => 'La fin doit être après le début.']; + } + if (!(new TimeRange($start, $end))->isWithin(new TimeRange($slot->startTime(), $slot->endTime()))) { + return ['startTime' => 'La plage doit rester dans le créneau du groupe titulaire.']; + } + + return []; + } +} diff --git a/templates/mail/exchange-decided.html.php b/templates/mail/exchange-decided.html.php new file mode 100644 index 00000000..8809a710 --- /dev/null +++ b/templates/mail/exchange-decided.html.php @@ -0,0 +1,26 @@ + +

+

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('