Files
Lesstime/src/Command/RestoreMissingUsersCommand.php
T
Matthieu 133f205393
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
test(user) : couvre le soft-delete + désarchivage admin et corrige les retours de review
- ajoute des tests fonctionnels (archive au DELETE, exclusion de la
  collection, listing/désarchivage admin, anti-auto-archivage) et un test
  unitaire du ArchivedUserChecker
- expose un filtre BooleanFilter `archived` + bypass admin dans
  ExcludeArchivedUserExtension pour lister les archivés (?archived=true)
- rend `archived` modifiable par un admin (groupe user:write + ApiProperty
  ROLE_ADMIN) → désarchivage possible via PATCH /api/users/{id}
- RestoreMissingUsersCommand : ne compte que les insertions réelles
  (ON CONFLICT DO NOTHING n'est plus comptabilisé à tort)
- relève memory_limit des tests à 512M (boot sérialiseur API Platform)
2026-06-26 16:14:11 +02:00

140 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Command;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function sprintf;
/**
* Recreates user rows that are still referenced by other tables but no longer
* exist (legacy hard-deletes performed before the foreign keys enforced
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
* (tasks, time entries, notifications…) is preserved and references become
* valid again, fixing the serialization crash (EntityNotFoundException), but
* the accounts cannot log in and are hidden from selectable user lists.
*
* Idempotent and non-destructive — nothing is deleted.
*/
#[AsCommand(
name: 'app:restore-missing-users',
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
)]
final class RestoreMissingUsersCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
// 1. Discover every (table, column) that references "user" via a foreign key.
$references = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT t.relname AS child_table, a.attname AS child_col
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_class rt ON rt.oid = c.confrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f' AND rt.relname = 'user'
ORDER BY t.relname, a.attname
SQL);
// 2. Collect distinct orphan ids across all those columns.
$missingIds = [];
foreach ($references as $ref) {
$table = $ref['child_table'];
$col = $ref['child_col'];
$ids = $this->connection->fetchFirstColumn(sprintf(
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
$this->connection->quoteIdentifier($col),
$this->connection->quoteIdentifier($table),
));
foreach ($ids as $id) {
$missingIds[(int) $id] = true;
}
}
$missingIds = array_keys($missingIds);
sort($missingIds);
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
if ([] === $missingIds) {
$io->success('No missing users referenced. Nothing to restore.');
return Command::SUCCESS;
}
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
if ($dryRun) {
$io->note('Dry run — no user recreated.');
return Command::SUCCESS;
}
// 3. Recreate each missing user as an archived placeholder, preserving its id.
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
$created = 0;
foreach ($missingIds as $id) {
$inserted = $this->connection->executeStatement(
<<<'SQL'
INSERT INTO "user"
(id, username, first_name, last_name, roles, password, created_at,
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
initial_leave_balance, archived)
VALUES
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
false, 1.0, 25.0, '06-01', 0.0, true)
ON CONFLICT (id) DO NOTHING
SQL,
[
'id' => $id,
'username' => sprintf('deleted-user-%d', $id),
'firstName' => 'Compte',
'lastName' => sprintf('supprimé #%d', $id),
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
'password' => $hash,
],
);
// ON CONFLICT may have skipped an already-present row — only count real inserts.
if ($inserted > 0) {
++$created;
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
} else {
$io->writeln(sprintf(' • user #%d already present — skipped', $id));
}
}
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
return Command::SUCCESS;
}
}