Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a2b268337 | |||
| f676b217bc | |||
| 8bebfe1595 | |||
| 49267ad2fb | |||
| d3abb584a9 | |||
| 98e3990fa5 | |||
| 172f79d348 | |||
| f221976573 | |||
| 133f205393 | |||
| d8d755d4c5 |
@@ -22,6 +22,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
interval: '1 minute'
|
||||
@@ -41,6 +42,7 @@ security:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -125,6 +125,10 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
||||
|
||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.44'
|
||||
app.version: '0.4.46'
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
empty-option-label="Aucun client"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="!isEditing"
|
||||
v-model="form.workflowId"
|
||||
:options="workflowOptions"
|
||||
label="Workflow"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
@@ -124,10 +131,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
|
||||
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const { getAll: getAllWorkflows } = useWorkflowService()
|
||||
const workflows = ref<Workflow[]>([])
|
||||
|
||||
const workflowOptions = computed(() =>
|
||||
workflows.value.map(w => ({ label: w.name, value: w.id }))
|
||||
)
|
||||
|
||||
function defaultWorkflowId(): number | null {
|
||||
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
workflowId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.workflowId = defaultWorkflowId()
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
@@ -269,6 +291,9 @@ async function handleSubmit() {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code
|
||||
if (form.workflowId) {
|
||||
payload.workflow = `/api/workflows/${form.workflowId}`
|
||||
}
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
workflows.value = await getAllWorkflows()
|
||||
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
|
||||
if (props.modelValue && !props.project && !form.workflowId) {
|
||||
form.workflowId = defaultWorkflowId()
|
||||
}
|
||||
} catch {
|
||||
// Workflows indisponibles, ignore (le serveur assignera le défaut)
|
||||
}
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
|
||||
* removing the row, preserving referential integrity (tasks, time entries,
|
||||
* notifications…). Existing users are kept active (archived = false).
|
||||
*/
|
||||
final class Version20260626153721 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add archived flag on user (soft delete)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP archived');
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<!-- API Platform's serializer/metadata boot is memory-hungry on the first
|
||||
call in a process (cold phpdoc + serializer metadata). 128M is too
|
||||
tight for non-paginated collections such as GET /api/users. -->
|
||||
<ini name="memory_limit" value="512M" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
|
||||
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
|
||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
||||
private ?Workflow $workflow = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
|
||||
|
||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Assigns the default workflow to a project when none was provided.
|
||||
* Guarantees the NOT NULL workflow_id constraint across every persistence
|
||||
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
|
||||
*/
|
||||
final readonly class ProjectDefaultWorkflowListener
|
||||
{
|
||||
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
|
||||
|
||||
public function prePersist(Project $project, PrePersistEventArgs $args): void
|
||||
{
|
||||
if (null !== $project->getWorkflow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$default = $this->workflowRepository->findDefault()
|
||||
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
|
||||
|
||||
if (null === $default) {
|
||||
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
|
||||
}
|
||||
|
||||
$project->setWorkflow($default);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
|
||||
class CreateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly WorkflowRepositoryInterface $workflowRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
@@ -30,6 +32,7 @@ class CreateProjectTool
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
?int $clientId = null,
|
||||
?int $workflowId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
@@ -52,6 +55,14 @@ class CreateProjectTool
|
||||
}
|
||||
$project->setClient($client);
|
||||
}
|
||||
if (null !== $workflowId) {
|
||||
$workflow = $this->workflowRepository->findById($workflowId);
|
||||
if (null === $workflow) {
|
||||
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
|
||||
}
|
||||
$project->setWorkflow($workflow);
|
||||
}
|
||||
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
|
||||
|
||||
$this->entityManager->persist($project);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Covers the soft-delete behaviour: deleting a user archives it (the row is
|
||||
* kept so referencing tasks/time entries still serialize), archived users are
|
||||
* hidden from the default collection but an admin can list and restore them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class UserArchiveApiTest extends WebTestCase
|
||||
{
|
||||
public function testDeleteArchivesUserInsteadOfRemovingIt(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$target = $this->createUser($em, 'archive-target-'.uniqid());
|
||||
$em->flush();
|
||||
$targetId = $target->getId();
|
||||
|
||||
$this->loginAdmin($client);
|
||||
$client->request('DELETE', '/api/users/'.$targetId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(User::class)->find($targetId);
|
||||
self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)');
|
||||
self::assertTrue($reloaded->isArchived(), 'User must be flagged archived');
|
||||
self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive');
|
||||
}
|
||||
|
||||
public function testAdminCannotArchiveOwnAccount(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->loginAdmin($client);
|
||||
$adminId = $this->userId('admin');
|
||||
|
||||
$client->request('DELETE', '/api/users/'.$adminId);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$em->clear();
|
||||
$admin = $em->getRepository(User::class)->find($adminId);
|
||||
self::assertFalse($admin->isArchived(), 'Admin must not have archived itself');
|
||||
}
|
||||
|
||||
public function testArchivedUserIsHiddenFromDefaultCollection(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$username = $this->createArchivedUser();
|
||||
|
||||
$this->loginAdmin($client);
|
||||
$client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
|
||||
self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list');
|
||||
}
|
||||
|
||||
public function testAdminCanListArchivedUsersViaFilter(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$username = $this->createArchivedUser();
|
||||
|
||||
$this->loginAdmin($client);
|
||||
$client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
|
||||
self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true');
|
||||
}
|
||||
|
||||
public function testAdminCanRestoreUserViaPatch(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $this->createUser($em, 'restore-target-'.uniqid());
|
||||
$user->setArchived(true);
|
||||
$em->flush();
|
||||
$userId = $user->getId();
|
||||
$em->clear();
|
||||
|
||||
$this->loginAdmin($client);
|
||||
$client->request('PATCH', '/api/users/'.$userId, server: [
|
||||
'CONTENT_TYPE' => 'application/merge-patch+json',
|
||||
], content: json_encode(['archived' => false]));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(User::class)->find($userId);
|
||||
self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user');
|
||||
}
|
||||
|
||||
private function createArchivedUser(): string
|
||||
{
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$username = 'archived-'.uniqid();
|
||||
$user = $this->createUser($em, $username);
|
||||
$user->setArchived(true);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
private function createUser(EntityManagerInterface $em, string $username): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setPassword('x');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
$em->persist($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function loginAdmin(KernelBrowser $client): void
|
||||
{
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
$client->loginUser($user);
|
||||
}
|
||||
|
||||
private function userId(string $username): int
|
||||
{
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
|
||||
return $user->getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\ProjectManagement;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Workflow;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
|
||||
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
|
||||
* - avec workflow → le workflow choisi est conservé.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProjectCreationWorkflowTest extends WebTestCase
|
||||
{
|
||||
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$client->loginUser($this->createManager($em));
|
||||
|
||||
$client->request('POST', '/api/projects', server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
], content: json_encode([
|
||||
'code' => $this->randomCode(),
|
||||
'name' => 'Projet sans workflow',
|
||||
]));
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertArrayHasKey('workflow', $data);
|
||||
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
|
||||
}
|
||||
|
||||
public function testCreateProjectWithExplicitWorkflow(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
|
||||
?? $em->getRepository(Workflow::class)->findOneBy([]);
|
||||
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
|
||||
|
||||
$client->loginUser($this->createManager($em));
|
||||
|
||||
$client->request('POST', '/api/projects', server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
], content: json_encode([
|
||||
'code' => $this->randomCode(),
|
||||
'name' => 'Projet avec workflow',
|
||||
'workflow' => '/api/workflows/'.$workflow->getId(),
|
||||
]));
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
|
||||
}
|
||||
|
||||
private function createManager(EntityManagerInterface $em): User
|
||||
{
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
|
||||
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('proj-create-'.uniqid());
|
||||
$user->setPassword('x');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
$user->addDirectPermission($permission);
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function randomCode(): string
|
||||
{
|
||||
$letters = '';
|
||||
for ($i = 0; $i < 6; ++$i) {
|
||||
$letters .= chr(random_int(65, 90));
|
||||
}
|
||||
|
||||
return $letters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Infrastructure\Security\ArchivedUserChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ArchivedUserCheckerTest extends TestCase
|
||||
{
|
||||
public function testArchivedUserIsRejectedPreAuth(): void
|
||||
{
|
||||
$user = new User()->setArchived(true);
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
|
||||
new ArchivedUserChecker()->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testActiveUserPassesPreAuth(): void
|
||||
{
|
||||
$user = new User()->setArchived(false);
|
||||
|
||||
new ArchivedUserChecker()->checkPreAuth($user);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testNonAppUserIsIgnored(): void
|
||||
{
|
||||
// A user that is not our entity must not be rejected by this checker.
|
||||
new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user