feat: Messaging asynchrone fiable avec retry, dead-letter et métriques
Les événements métier (emails d'invitation, reset password, activation) bloquaient la réponse API en étant traités de manière synchrone. Ce commit route ces événements vers un transport AMQP asynchrone avec un worker dédié, garantissant des réponses API rapides et une gestion robuste des échecs. Le retry utilise une stratégie Fibonacci (1s, 1s, 2s, 3s, 5s, 8s, 13s) qui offre un bon compromis entre réactivité et protection des services externes. Les messages qui épuisent leurs tentatives arrivent dans une dead-letter queue Doctrine avec alerte email à l'admin. La commande console CreateTestActivationTokenCommand détecte désormais les comptes déjà actifs et génère un token de réinitialisation de mot de passe au lieu d'un token d'activation, évitant une erreur bloquante lors de la ré-invitation par un admin.
This commit is contained in:
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -22,6 +27,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:dev:create-test-activation-token',
|
||||
@@ -31,9 +37,11 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivationTokenRepository $activationTokenRepository,
|
||||
private readonly PasswordResetTokenRepository $passwordResetTokenRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly TenantRegistry $tenantRegistry,
|
||||
private readonly Clock $clock,
|
||||
private readonly MessageBusInterface $eventBus,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -154,6 +162,11 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
|
||||
if ($user !== null) {
|
||||
$io->note(sprintf('User "%s" already exists, reusing existing account.', $email));
|
||||
|
||||
// Active users get a password reset token instead of activation
|
||||
if ($user->statut === StatutCompte::ACTIF) {
|
||||
return $this->createPasswordResetToken($user, $email, $tenantId, $tenantSubdomain, $baseUrl, $now, $io);
|
||||
}
|
||||
} else {
|
||||
$dateNaissance = $isMinor
|
||||
? $now->modify('-13 years') // 13 ans = mineur
|
||||
@@ -207,4 +220,52 @@ final class CreateTestActivationTokenCommand extends Command
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function createPasswordResetToken(
|
||||
User $user,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $tenantSubdomain,
|
||||
string $baseUrl,
|
||||
DateTimeImmutable $now,
|
||||
SymfonyStyle $io,
|
||||
): int {
|
||||
$io->warning('Le compte est déjà actif. Création d\'un token de réinitialisation de mot de passe à la place.');
|
||||
|
||||
$resetToken = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->passwordResetTokenRepository->save($resetToken);
|
||||
|
||||
foreach ($resetToken->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
$resetUrl = sprintf('%s/reset-password/%s', $baseUrl, $resetToken->tokenValue);
|
||||
|
||||
$io->success('Password reset token created successfully!');
|
||||
|
||||
$io->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User ID', (string) $user->id],
|
||||
['Email', $email],
|
||||
['Role', $user->role->value],
|
||||
['Tenant', $tenantSubdomain],
|
||||
['Status', $user->statut->value],
|
||||
['Token', $resetToken->tokenValue],
|
||||
['Expires', $resetToken->expiresAt->format('Y-m-d H:i:s')],
|
||||
]
|
||||
);
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln(sprintf('<info>Reset password URL:</info> <href=%s>%s</>', $resetUrl, $resetUrl));
|
||||
$io->writeln('');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use Twig\Environment;
|
||||
*
|
||||
* @see Story 1.4 - T4: Email alerte lockout
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
#[AsMessageHandler(bus: 'event.bus')]
|
||||
final readonly class SendLockoutAlertHandler
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
Reference in New Issue
Block a user