Merge branch 'develop' into fix/project-creation-workflow
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Domain\Enum\ContractType;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
@@ -47,7 +50,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
|
||||
new Get(
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('core.users.manage')",
|
||||
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
// Archived users are hidden from the default /users collection by
|
||||
// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true.
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $avatarFileName = null;
|
||||
|
||||
/**
|
||||
* Soft-delete flag. Archived users are kept for referential integrity
|
||||
* (tasks, time entries, notifications…) but cannot log in and are hidden
|
||||
* from selectable user lists.
|
||||
*/
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private bool $archived = false;
|
||||
|
||||
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||
|
||||
/** Whether this user is an employee subject to absence management. */
|
||||
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived;
|
||||
}
|
||||
|
||||
public function setArchived(bool $archived): static
|
||||
{
|
||||
$this->archived = $archived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function array_key_exists;
|
||||
|
||||
/**
|
||||
* Hides archived (soft-deleted) users from the `/users` collection so they are
|
||||
* no longer offered as assignees/collaborators, while existing references to
|
||||
* them (already stored on tasks, time entries…) keep resolving normally.
|
||||
*
|
||||
* An admin can opt back in to see archived users — e.g. to restore one — by
|
||||
* passing the `archived` query filter explicitly (`?archived=true`), in which
|
||||
* case the BooleanFilter declared on User handles the predicate instead.
|
||||
*/
|
||||
final readonly class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
|
||||
{
|
||||
public function __construct(private Security $security) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
if (User::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let an admin explicitly query archived users via ?archived=...
|
||||
$filters = $context['filters'] ?? [];
|
||||
if (array_key_exists('archived', $filters) && $this->security->isGranted('ROLE_ADMIN')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Soft-delete processor wired on the User `Delete` operation: instead of
|
||||
* removing the row (which would orphan every task / time entry / notification
|
||||
* referencing it and break their serialization), the user is archived. The
|
||||
* account is kept for referential integrity but can no longer log in
|
||||
* (ArchivedUserChecker) and is hidden from selectable user lists
|
||||
* (ExcludeArchivedUserExtension).
|
||||
*
|
||||
* @implements ProcessorInterface<User, null|User>
|
||||
*/
|
||||
final readonly class UserArchiveProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
|
||||
{
|
||||
assert($data instanceof User);
|
||||
|
||||
// Prevent an admin from archiving (locking out) their own account.
|
||||
$current = $this->security->getUser();
|
||||
if ($current instanceof User && $current->getId() === $data->getId()) {
|
||||
throw new AccessDeniedHttpException('You cannot archive your own account.');
|
||||
}
|
||||
|
||||
if ($data->isArchived()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data->setArchived(true);
|
||||
$data->setApiToken(null);
|
||||
$this->em->flush();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Rejects authentication for archived (soft-deleted) users, both at password
|
||||
* login and on every JWT-authenticated request, so an archived account is
|
||||
* effectively locked out while its data is preserved.
|
||||
*/
|
||||
final class ArchivedUserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||
{
|
||||
if ($user instanceof User && $user->isArchived()) {
|
||||
throw new CustomUserMessageAccountStatusException('This account has been archived.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
|
||||
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
|
||||
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\TaskInterface;
|
||||
use App\Shared\Domain\Contract\TaskTagInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
|
||||
/**
|
||||
* Shared serialization helpers for MCP tools.
|
||||
@@ -59,11 +61,8 @@ final class Serializer
|
||||
'name' => $project->getName(),
|
||||
'description' => $project->getDescription(),
|
||||
'color' => $project->getColor(),
|
||||
'client' => $project->getClient() ? [
|
||||
'id' => $project->getClient()->getId(),
|
||||
'name' => $project->getClient()->getName(),
|
||||
] : null,
|
||||
'archived' => $project->isArchived(),
|
||||
'client' => self::clientRef($project->getClient()),
|
||||
'archived' => $project->isArchived(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -516,4 +515,31 @@ final class Serializer
|
||||
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely serialize a project's client reference.
|
||||
*
|
||||
* The client association uses ON DELETE SET NULL, but a stale row may leave
|
||||
* a dangling foreign key (e.g. data imported with the constraint disabled).
|
||||
* In that case Doctrine returns an uninitialized proxy whose hydration
|
||||
* throws EntityNotFoundException; we treat such a reference as absent rather
|
||||
* than letting it crash the whole tool.
|
||||
*
|
||||
* @return null|array{id: ?int, name: ?string}
|
||||
*/
|
||||
private static function clientRef(?ClientInterface $client): ?array
|
||||
{
|
||||
if (null === $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
'id' => $client->getId(),
|
||||
'name' => $client->getName(),
|
||||
];
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user