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:
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user