feat: suppression client avec état pending_delete + commande nettoyage nocturne

Customer entity :
- Ajout STATE_PENDING_DELETE = 'pending_delete'
- Ajout isPendingDelete() pour vérification rapide

ClientsController::delete :
- Route POST /admin/clients/{id}/delete
- Met le state à pending_delete (pas de suppression immédiate)
- Flash message expliquant la suppression automatique cette nuit
- Bloqué si déjà en pending_delete

Template admin/clients/index.html.twig :
- Badge "Suppression" rouge avec animation pulse pour pending_delete
- Bouton "Supprimer" avec data-confirm (modal native navigateur)
- Si pending_delete : boutons Suspendre/Activer masqués,
  texte "En attente" affiché

CleanPendingDeleteCommand (app:clean:pending-delete) :
- Cherche tous les clients state = pending_delete
- Pour chaque client :
  1. Supprime le customer Stripe (API delete)
  2. Supprime de Meilisearch (removeCustomer)
  3. Supprime le Customer + User en cascade (Doctrine)
- Log chaque suppression + compteur final
- A planifier en cron nocturne : 0 2 * * * (2h du matin)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 11:31:55 +02:00
parent 64dfcd5721
commit a047f61911
4 changed files with 137 additions and 5 deletions

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Command;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use App\Service\MeilisearchService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:clean:pending-delete',
description: 'Supprime les clients en attente de suppression (state = pending_delete)',
)]
class CleanPendingDeleteCommand extends Command
{
public function __construct(
private CustomerRepository $customerRepository,
private EntityManagerInterface $em,
private MeilisearchService $meilisearch,
private LoggerInterface $logger,
#[Autowire(env: 'STRIPE_SK')] private string $stripeSecretKey = '',
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Nettoyage des clients en attente de suppression');
$customers = $this->customerRepository->findBy(['state' => Customer::STATE_PENDING_DELETE]);
if ([] === $customers) {
$io->success('Aucun client en attente de suppression.');
return Command::SUCCESS;
}
$io->text(\count($customers).' client(s) a supprimer.');
$deleted = 0;
foreach ($customers as $customer) {
$name = $customer->getFullName();
$email = $customer->getEmail();
$this->deleteFromStripe($customer);
$this->deleteFromMeilisearch($customer);
$user = $customer->getUser();
$this->em->remove($customer);
$this->em->remove($user);
$this->em->flush();
$this->logger->info('CleanPendingDelete: client supprime '.$email.' ('.$name.')');
$io->text(' Supprime : '.$name.' ('.$email.')');
++$deleted;
}
$io->success($deleted.' client(s) supprime(s).');
return Command::SUCCESS;
}
private function deleteFromStripe(Customer $customer): void
{
if ('' === $this->stripeSecretKey || 'sk_test_***' === $this->stripeSecretKey) {
return;
}
if (null === $customer->getStripeCustomerId()) {
return;
}
try {
\Stripe\Stripe::setApiKey($this->stripeSecretKey);
\Stripe\Customer::retrieve($customer->getStripeCustomerId())->delete();
} catch (\Throwable $e) {
$this->logger->warning('CleanPendingDelete: erreur Stripe '.$customer->getEmail().': '.$e->getMessage());
}
}
private function deleteFromMeilisearch(Customer $customer): void
{
try {
$this->meilisearch->removeCustomer($customer->getId());
} catch (\Throwable $e) {
$this->logger->warning('CleanPendingDelete: erreur Meilisearch '.$customer->getEmail().': '.$e->getMessage());
}
}
}

View File

@@ -240,4 +240,21 @@ class ClientsController extends AbstractController
return $this->redirectToRoute('app_admin_clients_index');
}
#[Route('/{id}/delete', name: 'delete', methods: ['POST'])]
public function delete(Customer $customer, EntityManagerInterface $em): Response
{
if ($customer->isPendingDelete()) {
$this->addFlash('error', 'Ce client est deja en attente de suppression.');
return $this->redirectToRoute('app_admin_clients_index');
}
$customer->setState(Customer::STATE_PENDING_DELETE);
$em->flush();
$this->addFlash('success', 'Client "'.$customer->getFullName().'" marque pour suppression. Il sera supprime automatiquement cette nuit.');
return $this->redirectToRoute('app_admin_clients_index');
}
}

View File

@@ -31,11 +31,13 @@ class Customer
public const STATE_ACTIVE = 'active';
public const STATE_SUSPENDED = 'suspended';
public const STATE_DISABLED = 'disabled';
public const STATE_PENDING_DELETE = 'pending_delete';
public const STATES = [
self::STATE_ACTIVE,
self::STATE_SUSPENDED,
self::STATE_DISABLED,
self::STATE_PENDING_DELETE,
];
#[ORM\Id]
@@ -403,6 +405,11 @@ class Customer
return self::STATE_ACTIVE === $this->state;
}
public function isPendingDelete(): bool
{
return self::STATE_PENDING_DELETE === $this->state;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;

View File

@@ -72,19 +72,30 @@
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Actif</span>
{% elseif customer.state == 'suspended' %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Suspendu</span>
{% elseif customer.state == 'pending_delete' %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded animate-pulse">Suppression</span>
{% else %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Desactive</span>
{% endif %}
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ customer.createdAt|date('d/m/Y') }}</td>
<td class="px-4 py-3 text-center">
<form method="post" action="{{ path('app_admin_clients_toggle', {id: customer.id}) }}">
{% if customer.isActive %}
<button type="submit" class="px-2 py-1 border-2 border-yellow-600 bg-white text-yellow-600 font-bold uppercase text-[10px] tracking-widest hover:bg-yellow-600 hover:text-white transition-all">Suspendre</button>
<div class="flex items-center justify-center gap-2">
{% if not customer.isPendingDelete %}
<form method="post" action="{{ path('app_admin_clients_toggle', {id: customer.id}) }}">
{% if customer.isActive %}
<button type="submit" class="px-2 py-1 border-2 border-yellow-600 bg-white text-yellow-600 font-bold uppercase text-[10px] tracking-widest hover:bg-yellow-600 hover:text-white transition-all">Suspendre</button>
{% else %}
<button type="submit" class="px-2 py-1 border-2 border-green-600 bg-white text-green-600 font-bold uppercase text-[10px] tracking-widest hover:bg-green-600 hover:text-white transition-all">Activer</button>
{% endif %}
</form>
<form method="post" action="{{ path('app_admin_clients_delete', {id: customer.id}) }}" data-confirm="Supprimer le client {{ customer.fullName }} ? Il sera supprime automatiquement cette nuit.">
<button type="submit" class="px-2 py-1 border-2 border-red-600 bg-white text-red-600 font-bold uppercase text-[10px] tracking-widest hover:bg-red-600 hover:text-white transition-all">Supprimer</button>
</form>
{% else %}
<button type="submit" class="px-2 py-1 border-2 border-green-600 bg-white text-green-600 font-bold uppercase text-[10px] tracking-widest hover:bg-green-600 hover:text-white transition-all">Activer</button>
<span class="text-[10px] text-red-500 font-bold uppercase">En attente</span>
{% endif %}
</form>
</div>
</td>
</tr>
{% else %}