Skip to content
Merged
6 changes: 4 additions & 2 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. **`<rb-booking-card>`** (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.

Expand All @@ -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)

Expand Down
17 changes: 15 additions & 2 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace App\Planning\Service;
namespace App\Planning\Notification;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace App\Planning\Service;
namespace App\Planning\Notification;

use App\Planning\Entity\FreeSlotBooking;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/Planning/Notification/NoSlotExchangeNotifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Planning\Notification;

use App\Planning\Entity\SlotException;

/** Objet nul : scripts et tests qui n'envoient aucune notification. */
final class NoSlotExchangeNotifier implements SlotExchangeNotifierInterface
{
public function requested(SlotException $exception): void
{
}

public function decided(SlotException $exception): void
{
}
}
87 changes: 87 additions & 0 deletions src/Planning/Notification/SlotExchangeNotifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\Planning\Notification;

use App\Account\Repository\UserRepositoryInterface;
use App\Group\Repository\GroupRepositoryInterface;
use App\Http\AfterResponseInterface;
use App\Mail\Mailbox;
use App\Planning\Entity\SlotException;
use App\Planning\Entity\SlotExceptionStatus;
use App\Planning\Repository\RecurringSlotRepositoryInterface;
use App\Support\FrenchDate;
use App\Support\HeaderText;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
* E-mails des demandes d'échange de créneau (#455). Envoyés APRÈS la réponse et sans jamais la faire échouer : un échec est journalisé
* avec des identifiants seulement. La demande va à l'adresse de CONTACT du groupe titulaire (comme les conversations entre groupes),
* la réponse à la personne qui a demandé. Le motif écrit par un groupe pour un autre n'est jamais repris dans un e-mail.
*/
final class SlotExchangeNotifier implements SlotExchangeNotifierInterface
{
public function __construct(
private readonly Mailbox $mailbox,
private readonly GroupRepositoryInterface $groups,
private readonly RecurringSlotRepositoryInterface $slots,
private readonly UserRepositoryInterface $users,
private readonly AfterResponseInterface $afterResponse,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}

public function requested(SlotException $exception): void
{
$this->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()]);
}
}
}
17 changes: 17 additions & 0 deletions src/Planning/Notification/SlotExchangeNotifierInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Planning\Notification;

use App\Planning\Entity\SlotException;

/** Notifications des demandes d'échange de créneau (#455) : jamais bloquantes, jamais de désabonnement général. */
interface SlotExchangeNotifierInterface
{
/** Une demande vient d'être faite : le groupe titulaire du créneau en est prévenu, à son adresse de contact. */
public function requested(SlotException $exception): void;

/** Le groupe titulaire vient d'accepter ou de refuser : la personne qui a fait la demande en est prévenue. */
public function decided(SlotException $exception): void;
}
Loading
Loading