feat(project-management) : migrate core Projects/Tasks domain into module (back)

Tranche 2 of LST-65. Mechanical, behaviour-preserving move of the core
business domain into src/Module/ProjectManagement/. API operations,
securities, uriTemplates and the 38 MCP tool names are all unchanged.

- 10 entities + 2 enums moved to Domain/{Entity,Enum}; intra-module
  relations stay concrete, cross-module relations go through contracts
  (Project.client -> ClientInterface, Task/TaskDocument users ->
  UserInterface).
- 9 repositories split into Domain/Repository interfaces + Doctrine impls,
  bound in services.yaml; consumers inject the interfaces. find() kept off
  the interfaces (ServiceEntityRepository ?object compat) -> findById().
- State (7), MCP tools (38), controller, CalDavService/RecurrenceCalculator,
  3 Doctrine listeners and SwitchWorkflowOutput moved under Infrastructure/.
- doctrine.yaml: ProjectManagement mapping + resolve_target_entities of the
  3 module contracts repointed to the module (ClientInterface stays legacy).
- ProjectManagementModule registered (id project-management, 4 RBAC perms,
  not re-wired); sidebar my-tasks/projects gated by the module.
- Legacy not-yet-modularised consumers (Mail/Gitea/BookStack, Serializer,
  fixtures, tests) swapped to the module FQCN — transitional coupling to be
  cleaned in 2.4/2.5/2.6.

159 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:54:59 +02:00
parent f119ec30ca
commit 23809f165e
119 changed files with 779 additions and 454 deletions
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource;
use Symfony\Component\Serializer\Attribute\Groups;
final class SwitchWorkflowOutput
{
#[Groups(['switch_workflow:read'])]
public int $projectId;
#[Groups(['switch_workflow:read'])]
public int $workflowId;
#[Groups(['switch_workflow:read'])]
public int $migratedTaskCount;
public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
{
$this->projectId = $projectId;
$this->workflowId = $workflowId;
$this->migratedTaskCount = $migratedTaskCount;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use App\Module\ProjectManagement\Infrastructure\Service\RecurrenceCalculator;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RecurrenceHandler
{
public function __construct(
private RecurrenceCalculator $calculator,
private TaskRepositoryInterface $taskRepository,
private TaskStatusRepositoryInterface $statusRepository,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
) {}
public function handleIfNeeded(Task $task, bool $wasAlreadyFinal): void
{
// Only trigger on STATUS CHANGE to isFinal
$currentStatus = $task->getStatus();
$isNowFinal = $currentStatus?->getIsFinal() ?? false;
if (!$isNowFinal || $wasAlreadyFinal) {
return; // No transition to final
}
$recurrence = $task->getRecurrence();
if (null === $recurrence) {
return; // Not a recurring task
}
if ($this->calculator->hasReachedEnd($recurrence)) {
return; // Recurrence is done
}
$nextStart = $this->calculator->getNextDate($task);
if (null === $nextStart) {
return;
}
// Archive current task, clear calendar UIDs
$savedEventUid = $task->getCalendarEventUid();
$task->setArchived(true);
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
// Create new task with same fields
$newTask = new Task();
$newTask->setProject($task->getProject());
$newTask->setTitle($task->getTitle());
$newTask->setDescription($task->getDescription());
$newTask->setAssignee($task->getAssignee());
$newTask->setEffort($task->getEffort());
$newTask->setPriority($task->getPriority());
$newTask->setGroup($task->getGroup());
$newTask->setRecurrence($recurrence);
$newTask->setSyncToCalendar($task->isSyncToCalendar());
// Copy tags
foreach ($task->getTags() as $tag) {
$newTask->addTag($tag);
}
// Set first non-final status
$firstStatus = $this->statusRepository->findFirstNonFinal();
$newTask->setStatus($firstStatus);
// Set recalculated dates
$newTask->setScheduledStart($nextStart);
$newTask->setScheduledEnd($this->calculator->getNextEnd($task, $nextStart));
$newTask->setDeadline($this->calculator->getNextDeadline($task, $nextStart));
// Copy calendar event UID (recurring VEVENT is shared)
$newTask->setCalendarEventUid($savedEventUid);
// Generate task number in transaction
$this->entityManager->wrapInTransaction(function () use ($newTask): void {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject());
$newTask->setNumber($maxNumber + 1);
$this->entityManager->persist($newTask);
$this->entityManager->flush();
});
// Increment occurrence count (with optimistic locking via @Version)
$recurrence->incrementOccurrenceCount();
$this->entityManager->flush();
// Sync new task's VTODO (new deadline) to Zimbra
if ($newTask->isSyncToCalendar() && $newTask->getDeadline()) {
$uid = $this->calDavService->createTodo($newTask);
if ($uid) {
$newTask->setCalendarTodoUid($uid);
$newTask->setCalendarSyncError(null);
$this->entityManager->flush();
}
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource\SwitchWorkflowOutput;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* Wraps the switch-workflow operation for a project.
* Input: Project (URI variable) + body { workflowId, mapping: { sourceStatusId: targetStatusId|null } }.
*
* @implements ProcessorInterface<Project, SwitchWorkflowOutput>
*/
final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput
{
/** @var Project $project */
$project = $data;
$request = $context['request'] ?? null;
$body = $request ? json_decode($request->getContent(), true) : [];
$workflowId = $body['workflowId'] ?? null;
$mapping = $body['mapping'] ?? [];
if (!is_int($workflowId) || !is_array($mapping)) {
throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).');
}
$targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId);
if (!$targetWorkflow instanceof Workflow) {
throw new NotFoundHttpException('Target workflow not found.');
}
// 1) Lister les statuts source effectivement référencés par les tâches du projet
$rows = $this->entityManager->getConnection()->fetchAllAssociative(
'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL',
['pid' => $project->getId()],
);
$referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows);
// 2) Vérifier que chaque source a un mapping
$missing = [];
foreach ($referencedSourceIds as $srcId) {
if (!array_key_exists((string) $srcId, $mapping)) {
$missing[] = $srcId;
}
}
if ([] !== $missing) {
throw new HttpException(422, 'Missing mapping for source status IDs: '.implode(', ', $missing));
}
// 3) Valider que chaque target appartient au workflow cible (ou est null)
foreach ($mapping as $srcId => $targetId) {
if (null === $targetId) {
continue;
}
$target = $this->entityManager->find(TaskStatus::class, $targetId);
if (!$target instanceof TaskStatus
|| $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) {
throw new HttpException(422, sprintf(
'Target status %s does not belong to workflow %d.',
var_export($targetId, true),
$targetWorkflow->getId(),
));
}
}
// 4) Transaction unique
$conn = $this->entityManager->getConnection();
$conn->beginTransaction();
try {
$migrated = 0;
foreach ($mapping as $srcId => $targetId) {
$affected = $conn->executeStatement(
'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid',
['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId],
);
$migrated += $affected;
}
$project->setWorkflow($targetWorkflow);
$this->entityManager->flush();
$conn->commit();
} catch (Throwable $e) {
$conn->rollBack();
throw $e;
}
return new SwitchWorkflowOutput(
projectId: $project->getId(),
workflowId: $targetWorkflow->getId(),
migratedTaskCount: $migrated,
);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskCalendarProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
* @param ProcessorInterface<Task, Task> $removeProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
private RecurrenceHandler $recurrenceHandler,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Delete) {
$eventUid = $data->getCalendarEventUid();
$todoUid = $data->getCalendarTodoUid();
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
if ($eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if ($todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return $result;
}
// Detect isFinal transition using Doctrine UnitOfWork.
// $data already has the NEW values (API Platform deserialized the PATCH).
// UnitOfWork originalEntityData stores the DB snapshot with entity references for relations.
$uow = $this->entityManager->getUnitOfWork();
$originalData = $uow->getOriginalEntityData($data);
$wasAlreadyFinal = false;
if (isset($originalData['status']) && $originalData['status'] instanceof TaskStatus) {
$wasAlreadyFinal = $originalData['status']->getIsFinal();
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Sync to Zimbra after DB flush
$this->calDavService->syncTask($data);
$this->entityManager->flush();
// Check for recurrence auto-creation (only on STATUS CHANGE to isFinal)
$this->recurrenceHandler->handleIfNeeded($data, $wasAlreadyFinal);
return $result;
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
use function in_array;
/**
* @implements ProcessorInterface<TaskDocument, TaskDocument>
*/
final readonly class TaskDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed', 'application/gzip',
'application/json', 'application/xml', 'text/xml',
];
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz',
'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml',
];
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private FileSource $fileSource,
private SharePathResolver $pathResolver,
private string $uploadDir,
) {}
/**
* @param TaskDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
// configuration de sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
// Deux modes de création : upload d'un fichier (multipart) ou lien vers un fichier du partage SMB (JSON).
$sharePath = $this->extractSharePath($request);
$document = null !== $sharePath
? $this->createShareLink($request, $sharePath)
: $this->createUpload($request);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document;
}
private function extractSharePath(Request $request): ?string
{
// Lien SMB : champ multipart/form OU corps JSON { "sharePath": "..." }
$fromForm = $request->request->get('sharePath');
if (is_string($fromForm) && '' !== $fromForm) {
return $fromForm;
}
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
$payload = json_decode($request->getContent() ?: '{}', true);
if (is_array($payload) && isset($payload['sharePath']) && is_string($payload['sharePath']) && '' !== $payload['sharePath']) {
return $payload['sharePath'];
}
}
return null;
}
private function createUpload(Request $request): TaskDocument
{
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$task = $this->resolveTask($request->request->get('task', ''));
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$fileSize = $file->getSize();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
}
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
return $document;
}
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
{
$taskIri = $request->request->get('task');
if (!is_string($taskIri) || '' === $taskIri) {
$payload = json_decode($request->getContent() ?: '{}', true);
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
}
$task = $this->resolveTask((string) $taskIri);
try {
$path = $this->pathResolver->normalizeRelative($rawSharePath);
} catch (InvalidPathException) {
throw new BadRequestHttpException('Invalid share path.');
}
if ('' === $path) {
throw new BadRequestHttpException('A share path is required.');
}
$entry = $this->findShareEntry($path);
if (null === $entry) {
throw new BadRequestHttpException('File not found on the share.');
}
if (!in_array($entry->mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $entry->mimeType));
}
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($entry->name);
$document->setSharePath($path);
$document->setMimeType($entry->mimeType);
$document->setSize($entry->size);
return $document;
}
/**
* Récupère les métadonnées (taille, type) du fichier sur le partage en listant son dossier parent.
*/
private function findShareEntry(string $path): ?FileEntry
{
$parent = dirname($path);
$parent = ('.' === $parent || '/' === $parent) ? '' : $parent;
$name = basename($path);
try {
$entries = $this->fileSource->dir($parent);
} catch (ShareNotConfiguredException) {
throw new BadRequestHttpException('Share not configured.');
} catch (ShareConnectionException) {
throw new BadRequestHttpException('Unable to reach the share.');
}
foreach ($entries as $entry) {
if (!$entry->isDir && $entry->name === $name) {
return $entry;
}
}
return null;
}
private function resolveTask(string $taskIri): Task
{
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
}
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
return $task;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|TaskDocument|null
{
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item
if (isset($uriVariables['id'])) {
return $repo->find($uriVariables['id']);
}
// Collection
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
return $qb->getQuery()->getResult();
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private TaskRepositoryInterface $taskRepository,
private EntityManagerInterface $entityManager,
private CalDavService $calDavService,
) {}
/**
* @param Task $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
});
$this->calDavService->syncTask($data);
$this->entityManager->flush();
return $result;
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* @implements ProcessorInterface<Workflow, void>
*/
final readonly class WorkflowDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
/** @var Workflow $workflow */
$workflow = $data;
$count = (int) $this->entityManager->getConnection()->fetchOne(
'SELECT COUNT(*) FROM project WHERE workflow_id = :id',
['id' => $workflow->getId()],
);
if ($count > 0) {
throw new HttpException(409, sprintf(
'Workflow used by %d project(s). Reassign them before deleting.',
$count,
));
}
$this->entityManager->remove($workflow);
$this->entityManager->flush();
}
}