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:
97
src/Command/CleanPendingDeleteCommand.php
Normal file
97
src/Command/CleanPendingDeleteCommand.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user