diff --git a/bin/send-announcement.php b/bin/send-announcement.php new file mode 100644 index 00000000..4f5c0474 --- /dev/null +++ b/bin/send-announcement.php @@ -0,0 +1,46 @@ +get(AnnouncementService::class); + +if (isset($options['test'])) { + $report = $service->sendTest((string) $options['test']); + echo $report->sent() === 1 ? "Essai envoyé.\n" : "Essai en échec (voir le journal).\n"; + exit($report->failed() > 0 ? 1 : 0); +} + +if (!isset($options['send'])) { + $report = $service->dryRun(); + printf("Simulation : %d destinataire(s) à servir, %d déjà servi(s). Rien n'a été envoyé.\n", $report->pending(), $report->alreadySent()); + exit(0); +} + +$report = $service->sendToAll(); +printf("%d envoyé(s), %d échec(s), %d déjà servi(s).\n", $report->sent(), $report->failed(), $report->alreadySent()); +exit($report->failed() > 0 ? 1 : 0); diff --git a/config/services.php b/config/services.php index ce93b6e1..eb82dc4d 100644 --- a/config/services.php +++ b/config/services.php @@ -112,6 +112,8 @@ use App\Group\Service\GroupServiceInterface; use App\Http\AfterResponseInterface; use App\Http\DeferredAfterResponse; +use App\Account\Service\Announcement\AnnouncementLedger; +use App\Account\Service\Announcement\AnnouncementService; use App\Mail\Mailbox; use App\Mail\MailRenderer; use App\Account\Service\UserAdminServiceInterface; @@ -403,6 +405,12 @@ $c->get(MetricsRecorderInterface::class), )); + $container->set(AnnouncementService::class, fn ($c) => new AnnouncementService( + $c->get(UserRepositoryInterface::class), + $c->get(Mailbox::class), + new AnnouncementLedger(__DIR__ . '/../storage/announcement-sent.txt'), + )); + $container->set(MailerInterface::class, fn () => new \Symfony\Component\Mailer\Mailer( Transport::fromDsn($config['mailer']['dsn']), )); diff --git a/src/Account/Service/Announcement/AnnouncementLedger.php b/src/Account/Service/Announcement/AnnouncementLedger.php new file mode 100644 index 00000000..b182cdff --- /dev/null +++ b/src/Account/Service/Announcement/AnnouncementLedger.php @@ -0,0 +1,48 @@ +|null */ + private ?array $sent = null; + + public function __construct(private readonly string $path) + { + } + + public function has(int $userId): bool + { + return isset($this->load()[$userId]); + } + + public function add(int $userId): void + { + // Écrit tout de suite : un envoi interrompu à mi-parcours ne renvoie pas aux comptes déjà servis. + file_put_contents($this->path, $userId . "\n", FILE_APPEND | LOCK_EX); + $this->sent = $this->load() + [$userId => true]; + } + + /** @return array */ + private function load(): array + { + if ($this->sent !== null) { + return $this->sent; + } + $this->sent = []; + $lines = is_file($this->path) ? file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : false; + foreach ($lines ?: [] as $line) { + if (ctype_digit($line)) { + $this->sent[(int) $line] = true; + } + } + + return $this->sent; + } +} diff --git a/src/Account/Service/Announcement/AnnouncementReport.php b/src/Account/Service/Announcement/AnnouncementReport.php new file mode 100644 index 00000000..8658d8db --- /dev/null +++ b/src/Account/Service/Announcement/AnnouncementReport.php @@ -0,0 +1,38 @@ +sent; + } + + public function failed(): int + { + return $this->failed; + } + + public function alreadySent(): int + { + return $this->alreadySent; + } + + /** Comptes à qui l'annonce partirait lors d'un envoi réel. */ + public function pending(): int + { + return $this->pending; + } +} diff --git a/src/Account/Service/Announcement/AnnouncementService.php b/src/Account/Service/Announcement/AnnouncementService.php new file mode 100644 index 00000000..eafcda68 --- /dev/null +++ b/src/Account/Service/Announcement/AnnouncementService.php @@ -0,0 +1,80 @@ +split(); + + return new AnnouncementReport(0, 0, count($done), count($todo)); + } + + public function sendToAll(): AnnouncementReport + { + [$todo, $done] = $this->split(); + $sent = $failed = 0; + foreach ($todo as $user) { + if ($this->mailbox->sendSafely(fn (): Email => $this->build($user->email()), "annonce, compte #{$user->id()}")) { + $this->ledger->add($user->id()); + ++$sent; + } else { + ++$failed; + } + } + + return new AnnouncementReport($sent, $failed, count($done), count($todo)); + } + + public function sendTest(string $to): AnnouncementReport + { + $ok = $this->mailbox->sendSafely(fn (): Email => $this->build($to), 'annonce, essai'); + + return new AnnouncementReport($ok ? 1 : 0, $ok ? 0 : 1, 0, 1); + } + + /** @return array{0: list<\App\Account\Entity\User>, 1: list<\App\Account\Entity\User>} comptes actifs à servir, comptes déjà servis */ + private function split(): array + { + $todo = $done = []; + foreach ($this->users->findAll() as $user) { + if (!$user->isActive()) { + continue; + } + if ($this->ledger->has($user->id())) { + $done[] = $user; + } else { + $todo[] = $user; + } + } + + return [$todo, $done]; + } + + private function build(string $to): Email + { + return $this->mailbox->compose($to, "l'appli #B27 est ouverte", 'announcement', [ + 'link' => $this->mailbox->url('/login'), + 'forgotLink' => $this->mailbox->url('/forgot-password'), + 'preheader' => 'Voir à l’avance si le local est libre : mode d’emploi en 4 points.', + ]); + } +} diff --git a/templates/mail/announcement.html.php b/templates/mail/announcement.html.php new file mode 100644 index 00000000..420ec157 --- /dev/null +++ b/templates/mail/announcement.html.php @@ -0,0 +1,33 @@ + +

L'appli #B27 est ouverte !

+

Bonjour,

+

L'appli #B27 est maintenant disponible pour tous les groupes du local. Son but : voir à l'avance si le local est libre, sans avoir à demander à tout le monde.

+ +

Se connecter

+

Première fois ? Votre compte existe déjà : pour l'activer, passez par « Mot de passe oublié » avec votre adresse e-mail et choisissez votre mot de passe.

+ + + + +
+ Activer mon compte +
+

Compte déjà configuré ? Rien à refaire, connectez-vous comme d'habitude.

+ + + + +
+ Me connecter +
+

À quoi ça sert

+

1. Voir le planning. Les créneaux du local apparaissent dans l'appli : vous voyez d'un coup d'œil ce qui est pris et ce qui est libre.

+

2. Réserver un créneau libre. Un créneau disponible se réserve directement depuis l'appli, et votre réservation y apparaît aussitôt.

+

3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Contactez le groupe concerné depuis l'appli : la demande d'échange lui apparaît dans l'appli.

+

4. Écrire aux autres membres. La messagerie permet d'échanger entre membres directement dans l'appli.

+ diff --git a/templates/mail/announcement.txt.php b/templates/mail/announcement.txt.php new file mode 100644 index 00000000..6ba42d53 --- /dev/null +++ b/templates/mail/announcement.txt.php @@ -0,0 +1,25 @@ + +Bonjour, + +L'appli #B27 est ouverte ! + +L'appli #B27 est maintenant disponible pour tous les groupes du local. Son but : voir à l'avance si le local est libre, sans avoir à demander à tout le monde. + +SE CONNECTER +- Première fois ? Votre compte existe déjà : pour l'activer, passez par « Mot de passe oublié » avec votre adresse e-mail et choisissez votre mot de passe. Activer mon compte : + + +- Compte déjà configuré ? Rien à refaire, connectez-vous comme d'habitude. Me connecter : + + + +À QUOI ÇA SERT +1. Voir le planning. Les créneaux du local apparaissent dans l'appli : vous voyez d'un coup d'œil ce qui est pris et ce qui est libre. +2. Réserver un créneau libre. Un créneau disponible se réserve directement depuis l'appli, et votre réservation y apparaît aussitôt. +3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Contactez le groupe concerné depuis l'appli : la demande d'échange lui apparaît dans l'appli. +4. Écrire aux autres membres. La messagerie permet d'échanger entre membres directement dans l'appli. diff --git a/tests/Account/Service/Announcement/AnnouncementServiceTest.php b/tests/Account/Service/Announcement/AnnouncementServiceTest.php new file mode 100644 index 00000000..be5c04d8 --- /dev/null +++ b/tests/Account/Service/Announcement/AnnouncementServiceTest.php @@ -0,0 +1,137 @@ +mailer = new RecordingMailer(); + $this->ledgerPath = sys_get_temp_dir() . '/rb-announcement-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + @unlink($this->ledgerPath); + } + + private function user(int $id, string $email, bool $active = true): User + { + return new User($id, $email, 'hash', 'Nom ' . $id, UserRole::Musicien, $active, 0, null); + } + + /** @param list $users */ + private function service(array $users, ?MailerInterface $mailer = null): AnnouncementService + { + $repository = $this->createStub(UserRepositoryInterface::class); + $repository->method('findAll')->willReturn($users); + + return new AnnouncementService( + $repository, + new Mailbox($mailer ?? $this->mailer, 'no-reply@rehearsalbox.example', 'https://b27.example'), + new AnnouncementLedger($this->ledgerPath), + ); + } + + #[Test] + public function testDryRunCountsTheActiveRecipientsAndSendsNothing(): void + { + $report = $this->service([$this->user(1, 'a@x.test'), $this->user(2, 'b@x.test'), $this->user(3, 'off@x.test', false)])->dryRun(); + + self::assertSame(2, $report->pending()); + self::assertSame(0, $report->sent()); + self::assertSame([], $this->mailer->sent); + self::assertFileDoesNotExist($this->ledgerPath); + } + + #[Test] + public function testSendMailsEveryActiveAccountWithTheTwoLinksAndSkipsInactiveOnes(): void + { + $report = $this->service([$this->user(1, 'a@x.test'), $this->user(2, 'b@x.test'), $this->user(3, 'off@x.test', false)])->sendToAll(); + + self::assertSame(2, $report->sent()); + self::assertSame(0, $report->failed()); + self::assertCount(2, $this->mailer->sent); + self::assertSame('a@x.test', $this->mailer->sent[0]->getTo()[0]->getAddress()); + $html = (string) $this->mailer->sent[0]->getHtmlBody(); + self::assertStringContainsString('https://b27.example/forgot-password', $html); + self::assertStringContainsString('href="https://b27.example/login"', $html); + } + + #[Test] + public function testASecondRunSendsNothingMoreBecauseEachSentAccountIsRemembered(): void + { + $users = [$this->user(1, 'a@x.test'), $this->user(2, 'b@x.test')]; + $this->service($users)->sendToAll(); + $second = $this->service($users)->sendToAll(); + + self::assertSame(0, $second->sent()); + self::assertSame(2, $second->alreadySent()); + self::assertCount(2, $this->mailer->sent, 'aucun doublon'); + } + + #[Test] + public function testAFailedSendIsCountedNotRememberedAndRetriedOnTheNextRun(): void + { + $flaky = new class () implements MailerInterface { + public int $calls = 0; + + public function send(RawMessage $message, ?Envelope $envelope = null): void + { + if (++$this->calls === 1) { + throw new \RuntimeException('SMTP indisponible'); + } + } + }; + $users = [$this->user(1, 'a@x.test'), $this->user(2, 'b@x.test')]; + + $first = $this->service($users, $flaky)->sendToAll(); + self::assertSame(1, $first->failed()); + self::assertSame(1, $first->sent(), "l'échec d'un compte n'arrête pas les suivants"); + + $second = $this->service($users, $flaky)->sendToAll(); + self::assertSame(1, $second->sent(), 'seul le compte en échec est réessayé'); + self::assertSame(1, $second->alreadySent()); + } + + #[Test] + public function testTestSendGoesToOneAddressOnlyAndDoesNotTouchTheLedger(): void + { + $report = $this->service([$this->user(1, 'a@x.test')])->sendTest('moi@x.test'); + + self::assertSame(1, $report->sent()); + self::assertCount(1, $this->mailer->sent); + self::assertSame('moi@x.test', $this->mailer->sent[0]->getTo()[0]->getAddress()); + self::assertFileDoesNotExist($this->ledgerPath); + } + + #[Test] + public function testTheLedgerIgnoresGarbageLines(): void + { + file_put_contents($this->ledgerPath, "1\nabc\n\n2\n"); + $ledger = new AnnouncementLedger($this->ledgerPath); + + self::assertTrue($ledger->has(1)); + self::assertTrue($ledger->has(2)); + self::assertFalse($ledger->has(3)); + } +} diff --git a/tests/Mail/MailRendererTest.php b/tests/Mail/MailRendererTest.php index 11da45dd..a054b7f9 100644 --- a/tests/Mail/MailRendererTest.php +++ b/tests/Mail/MailRendererTest.php @@ -34,6 +34,7 @@ public function testEveryMailIsAFullHtmlDocumentWithTheBrandAndNoExternalResourc ['mention-reminder', ['mentionerName' => 'Alice', 'link' => self::LINK]], ['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]], ] as [$template, $data]) { $html = $this->renderer->render($template, $data)['html']; @@ -54,8 +55,8 @@ public function testEveryMailIsAFullHtmlDocumentWithTheBrandAndNoExternalResourc #[Test] public function testEveryMailHasABothHtmlAndPlainTextVersion(): void { - foreach (['password-reset', 'account-alert'] as $template) { - $mail = $this->renderer->render($template, ['link' => self::LINK]); + foreach (['password-reset', 'account-alert', 'announcement'] as $template) { + $mail = $this->renderer->render($template, ['link' => self::LINK, 'forgotLink' => self::LINK]); self::assertNotSame('', trim($mail['html']), $template); self::assertNotSame('', trim($mail['text']), $template); @@ -90,6 +91,35 @@ public function testPasswordResetEscapesAMaliciousLink(): void self::assertStringContainsString('<script>', $html); } + // --- Annonce de mise à disposition (#444) ----------------------------------------------- + + #[Test] + public function testAnnouncementExplainsTheAimTheTwoWaysToLogInAndTheFourMainFeaturesInBothVersions(): void + { + $mail = $this->renderer->render('announcement', ['link' => 'https://b27.example/login', 'forgotLink' => 'https://b27.example/forgot-password']); + + foreach ([$mail['html'], $mail['text']] as $body) { + self::assertStringContainsString('local est libre', $body, 'le but premier'); + self::assertStringContainsString('Mot de passe oublié', $body, 'activation du compte'); + self::assertStringContainsString('déjà', $body, 'compte déjà configuré : rien à refaire'); + self::assertStringContainsString('créneau libre', $body, 'réservation'); + self::assertStringContainsString('échange', $body, 'demande par contact du groupe'); + self::assertStringContainsString('messagerie', $body); + self::assertStringContainsString('https://b27.example/forgot-password', $body); + self::assertStringContainsString('https://b27.example/login', $body); + self::assertStringContainsString('Activer mon compte', $body); + self::assertStringContainsString('Me connecter', $body); + } + } + + #[Test] + public function testAnnouncementEscapesTheLinks(): void + { + $html = $this->renderer->render('announcement', ['link' => 'https://x.test/">', 'forgotLink' => 'https://x.test/">'])['html']; + + self::assertStringNotContainsString('