confirm() because the scheduler hands the command a StringInput, which reports itself * as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag * behaves the same way whether a person or cron is running it, and it makes the schedule entry * say out loud that the task destroys data. */ #[AsCommand( name: 'app:cleanup:users', description: 'Removes accounts that hold no role, no draft and no group booking reference', )] class CleanupUsersCommand extends Command { /** * How many accounts are removed between two flushes. The unit of work holds every scheduled * removal until then, and the first run has thousands of rows to get through, so it is a * memory bound rather than a policy one. */ private const BATCH_SIZE = 500; public function __construct( private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, ) { parent::__construct(); } protected function configure(): void { $this ->addOption( 'inactive-for', null, InputOption::VALUE_REQUIRED, 'Only consider accounts without a login for this many months (default: no such limit)', ) ->addOption( 'limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of accounts to delete in one run (default: no limit)', ) ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting') ->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null; $limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null; $dryRun = (bool) $input->getOption('dry-run'); $force = (bool) $input->getOption('force'); if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) { $io->error('--inactive-for and --limit have to be at least 1 when given.'); return Command::INVALID; } $inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null; $candidates = array_values(array_filter( $this->userRepository->findCleanupCandidates($inactiveSince), static fn (User $user): bool => self::isDisposable($user), )); // After the role filter, never before it: --limit caps the accounts actually deleted, and // the candidate query cannot tell which ones those are. if (null !== $limit) { $candidates = \array_slice($candidates, 0, $limit); } if ([] === $candidates) { $io->success('No accounts to remove.'); return Command::SUCCESS; } if (true === $dryRun || false === $force) { $io->table( ['E-Mail', 'Letzter Login', 'Rollen'], array_map(static fn (User $user): array => [ $user->getEmail(), $user->getLastLoginAt()?->format('Y-m-d') ?? '-', implode(', ', Role::assignedOnly($user->getRoles())) ?: '-', ], $candidates), ); $io->note(sprintf('Would delete %d account(s).', \count($candidates))); if (true === $dryRun) { return Command::SUCCESS; } // Not a dry run, just an invocation that forgot to say so. Reported as a failure so a // schedule entry missing --force turns into a mail rather than a task that quietly // does nothing every week. $io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.'); return Command::INVALID; } foreach ($candidates as $index => $user) { // One line per account, before the removal rather than after: once the row is gone // this is the only place left that records which account the uuid belonged to. Worth // a log_entry row per deletion, including the several thousand the first run writes — // they age out with the six-month log retention. $this->logger->info('Deleting account without roles', [ 'uuid' => $user->getUuid(), 'email' => $user->getEmail(), 'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM), 'roles' => Role::assignedOnly($user->getRoles()), ]); $this->entityManager->remove($user); if (0 === ($index + 1) % self::BATCH_SIZE) { $this->entityManager->flush(); } } $this->entityManager->flush(); $count = \count($candidates); $this->logger->info('Deleted accounts without roles', [ 'count' => $count, 'inactiveSince' => $inactiveSince?->format('Y-m-d'), ]); $io->success(sprintf('Deleted %d account(s).', $count)); return Command::SUCCESS; } /** * Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a * baseline, so "customer only" and "nothing at all" are the same state — the second being the * spelling accounts kept that have not signed in since the fallback landed. * * Pending markers grant nothing, but they are a nomination waiting for an administrator, so an * account carrying one stays until somebody has ruled on it. */ private static function isDisposable(User $user): bool { $roles = $user->getRoles(); return [] === Role::pendingOnly($roles) && [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]); } }