133f205393
- 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)
140 lines
5.4 KiB
PHP
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;
|
|
}
|
|
}
|