Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions bin/send-announcement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

// Annonce « l'appli est ouverte » (#444) : un e-mail à chaque compte actif. À lancer À LA MAIN, une fois.
// php bin/send-announcement.php simulation : compte les destinataires, n'envoie RIEN
// php bin/send-announcement.php --test=moi@exemple.fr envoi d'essai à UNE adresse (sans mémoire)
// php bin/send-announcement.php --send envoi réel ; chaque compte servi est noté dans storage/announcement-sent.txt,
// relancer ne renvoie qu'aux comptes en échec ou ajoutés depuis
// Code de sortie : 0 = succès, 1 = au moins un échec d'envoi, 2 = option inconnue. Le bilan ne contient aucune adresse.

if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}

require __DIR__ . '/../vendor/autoload.php';

use App\Account\Service\Announcement\AnnouncementService;

$options = getopt('', ['send', 'test:']);
foreach (array_slice($argv, 1) as $arg) {
if ($arg !== '--send' && !str_starts_with($arg, '--test=')) {
fwrite(STDERR, "Option inconnue. Voir l'en-tête de bin/send-announcement.php.\n");
exit(2);
}
}

$config = require __DIR__ . '/../config/config.php';
$service = (require __DIR__ . '/../config/services.php')($config)->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);
8 changes: 8 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']),
));
Expand Down
48 changes: 48 additions & 0 deletions src/Account/Service/Announcement/AnnouncementLedger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Account\Service\Announcement;

/**
* Mémoire de l'annonce (#444) : un identifiant de compte par ligne, ajouté dès que son e-mail est parti. Un fichier plutôt qu'une
* table : l'annonce est un envoi unique, une migration n'aurait pas de second usage. Aucune adresse n'y est écrite.
*/
final class AnnouncementLedger
{
/** @var array<int, true>|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<int, true> */
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;
}
}
38 changes: 38 additions & 0 deletions src/Account/Service/Announcement/AnnouncementReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Account\Service\Announcement;

/** Bilan d'un passage de l'annonce (#444). Aucune adresse : seulement des comptes. */
final class AnnouncementReport
{
public function __construct(
private readonly int $sent,
private readonly int $failed,
private readonly int $alreadySent,
private readonly int $pending,
) {
}

public function sent(): int
{
return $this->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;
}
}
80 changes: 80 additions & 0 deletions src/Account/Service/Announcement/AnnouncementService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace App\Account\Service\Announcement;

use App\Account\Repository\UserRepositoryInterface;
use App\Mail\Mailbox;
use Symfony\Component\Mime\Email;

/**
* E-mail d'annonce « l'appli est ouverte » (#444) : une fois, à chaque compte actif. Trois gestes, du plus sûr au plus engageant :
* simulation (rien ne part), essai à UNE adresse (sans mémoire), envoi réel (chaque compte servi est noté : relancer ne crée aucun doublon).
*/
final class AnnouncementService
{
public function __construct(
private readonly UserRepositoryInterface $users,
private readonly Mailbox $mailbox,
private readonly AnnouncementLedger $ledger,
) {
}

public function dryRun(): AnnouncementReport
{
[$todo, $done] = $this->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.',
]);
}
}
33 changes: 33 additions & 0 deletions templates/mail/announcement.html.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
/**
* @var string $link page de connexion
* @var string $forgotLink page « Mot de passe oublié » (activation du compte)
*/
?>
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1c1a17;">L'appli #B27 est ouverte !</h1>
<p style="margin:0 0 16px 0;">Bonjour,</p>
<p style="margin:0 0 24px 0;">L'appli #B27 est maintenant disponible pour tous les groupes du local. Son but : <strong>voir à l'avance si le local est libre</strong>, sans avoir à demander à tout le monde.</p>

<h2 style="margin:0 0 8px 0;font-size:17px;color:#1c1a17;">Se connecter</h2>
<p style="margin:0 0 16px 0;"><strong>Première fois ?</strong> 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.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;">
<tr>
<td style="background:#b5654a;border-radius:4px;">
<a href="<?= e($forgotLink) ?>" style="display:inline-block;padding:14px 24px;font-weight:bold;font-size:16px;color:#ffffff;text-decoration:none;">Activer mon compte</a>
</td>
</tr>
</table>
<p style="margin:0 0 16px 0;"><strong>Compte déjà configuré ?</strong> Rien à refaire, connectez-vous comme d'habitude.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;">
<tr>
<td style="background:#b5654a;border-radius:4px;">
<a href="<?= e($link) ?>" style="display:inline-block;padding:14px 24px;font-weight:bold;font-size:16px;color:#ffffff;text-decoration:none;">Me connecter</a>
</td>
</tr>
</table>
<h2 style="margin:0 0 8px 0;font-size:17px;color:#1c1a17;">À quoi ça sert</h2>
<p style="margin:0 0 8px 0;"><strong>1. Voir le planning.</strong> 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.</p>
<p style="margin:0 0 8px 0;"><strong>2. Réserver un créneau libre.</strong> Un créneau disponible se réserve directement depuis l'appli, et votre réservation y apparaît aussitôt.</p>
<p style="margin:0 0 8px 0;"><strong>3. Demander un échange de créneau.</strong> 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.</p>
<p style="margin:0;"><strong>4. Écrire aux autres membres.</strong> La messagerie permet d'échanger entre membres directement dans l'appli.</p>

25 changes: 25 additions & 0 deletions templates/mail/announcement.txt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
/**
* @var string $link
* @var string $forgotLink
*/
?>
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 :
<?= $forgotLink ?>

- Compte déjà configuré ? Rien à refaire, connectez-vous comme d'habitude. Me connecter :
<?= $link ?>


À 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.
Loading
Loading