diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index dfab344..88f104e 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -39,10 +39,12 @@ use Symfony\Component\Serializer\Attribute\Groups; normalizationContext: ['groups' => ['me:read']], ), new Get( + security: "is_granted('ROLE_USER')", normalizationContext: ['groups' => ['user:list']], ), new GetCollection( paginationEnabled: false, + security: "is_granted('ROLE_USER')", normalizationContext: ['groups' => ['user:list']], ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), diff --git a/src/Module/Core/Domain/Repository/UserRepositoryInterface.php b/src/Module/Core/Domain/Repository/UserRepositoryInterface.php index 4545c08..6308542 100644 --- a/src/Module/Core/Domain/Repository/UserRepositoryInterface.php +++ b/src/Module/Core/Domain/Repository/UserRepositoryInterface.php @@ -11,6 +11,13 @@ interface UserRepositoryInterface { public function findById(int $id): ?UserInterface; + /** + * @param int[] $ids + * + * @return list + */ + public function findByIds(array $ids): array; + /** * @return list */ diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php index c1d9777..d69e010 100644 --- a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php @@ -26,6 +26,25 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo return $this->find($id); } + /** + * @param int[] $ids + * + * @return list + */ + public function findByIds(array $ids): array + { + if ([] === $ids) { + return []; + } + + return $this->createQueryBuilder('u') + ->where('u.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery() + ->getResult() + ; + } + /** * @return list */ diff --git a/src/Module/Integration/Infrastructure/ApiPlatform/Resource/BookStackLink.php b/src/Module/Integration/Infrastructure/ApiPlatform/Resource/BookStackLink.php index fee1300..6dac12a 100644 --- a/src/Module/Integration/Infrastructure/ApiPlatform/Resource/BookStackLink.php +++ b/src/Module/Integration/Infrastructure/ApiPlatform/Resource/BookStackLink.php @@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups; ], normalizationContext: ['groups' => ['bookstack_link:read']], provider: BookStackLinkProvider::class, - security: "is_granted('IS_AUTHENTICATED_FULLY')", + security: "is_granted('ROLE_USER')", ), new Post( uriTemplate: '/tasks/{taskId}/bookstack/links', @@ -35,7 +35,7 @@ use Symfony\Component\Serializer\Attribute\Groups; normalizationContext: ['groups' => ['bookstack_link:read']], provider: BookStackLinkProvider::class, processor: BookStackLinkProcessor::class, - security: "is_granted('IS_AUTHENTICATED_FULLY')", + security: "is_granted('ROLE_USER')", ), new Delete( uriTemplate: '/tasks/{taskId}/bookstack/links/{id}', @@ -45,7 +45,7 @@ use Symfony\Component\Serializer\Attribute\Groups; ], provider: BookStackLinkProvider::class, processor: BookStackLinkProcessor::class, - security: "is_granted('IS_AUTHENTICATED_FULLY')", + security: "is_granted('ROLE_USER')", ), ], )] diff --git a/src/Module/Integration/Infrastructure/Controller/ShareBrowseController.php b/src/Module/Integration/Infrastructure/Controller/ShareBrowseController.php index dca367e..a464765 100644 --- a/src/Module/Integration/Infrastructure/Controller/ShareBrowseController.php +++ b/src/Module/Integration/Infrastructure/Controller/ShareBrowseController.php @@ -24,7 +24,7 @@ class ShareBrowseController extends AbstractController ) {} #[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)] - #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[IsGranted('ROLE_USER')] public function __invoke(Request $request): JsonResponse { $rawPath = (string) $request->query->get('path', ''); diff --git a/src/Module/Integration/Infrastructure/Controller/ShareDownloadController.php b/src/Module/Integration/Infrastructure/Controller/ShareDownloadController.php index 9973f4f..27292a3 100644 --- a/src/Module/Integration/Infrastructure/Controller/ShareDownloadController.php +++ b/src/Module/Integration/Infrastructure/Controller/ShareDownloadController.php @@ -29,7 +29,7 @@ class ShareDownloadController extends AbstractController ) {} #[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)] - #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[IsGranted('ROLE_USER')] public function __invoke(Request $request): Response { $rawPath = (string) $request->query->get('path', ''); diff --git a/src/Module/Integration/Infrastructure/Controller/ShareSearchController.php b/src/Module/Integration/Infrastructure/Controller/ShareSearchController.php index 7a955f9..eae046e 100644 --- a/src/Module/Integration/Infrastructure/Controller/ShareSearchController.php +++ b/src/Module/Integration/Infrastructure/Controller/ShareSearchController.php @@ -24,7 +24,7 @@ class ShareSearchController extends AbstractController ) {} #[Route('/api/share/search', name: 'share_search', methods: ['GET'], priority: 1)] - #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[IsGranted('ROLE_USER')] public function __invoke(Request $request): JsonResponse { $query = trim((string) $request->query->get('q', '')); diff --git a/src/Module/Integration/Infrastructure/Controller/ShareStatusController.php b/src/Module/Integration/Infrastructure/Controller/ShareStatusController.php index 3776160..0743f6c 100644 --- a/src/Module/Integration/Infrastructure/Controller/ShareStatusController.php +++ b/src/Module/Integration/Infrastructure/Controller/ShareStatusController.php @@ -17,7 +17,7 @@ class ShareStatusController extends AbstractController ) {} #[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)] - #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[IsGranted('ROLE_USER')] public function __invoke(): JsonResponse { $config = $this->configRepository->findSingleton(); diff --git a/src/Module/ProjectManagement/Infrastructure/ApiPlatform/State/RecurrenceHandler.php b/src/Module/ProjectManagement/Infrastructure/ApiPlatform/State/RecurrenceHandler.php index 82ad1d6..7a98b7f 100644 --- a/src/Module/ProjectManagement/Infrastructure/ApiPlatform/State/RecurrenceHandler.php +++ b/src/Module/ProjectManagement/Infrastructure/ApiPlatform/State/RecurrenceHandler.php @@ -81,8 +81,12 @@ final readonly class RecurrenceHandler $newTask->setCalendarEventUid($savedEventUid); // Generate task number in transaction - $this->entityManager->wrapInTransaction(function () use ($newTask): void { - $maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject()); + $project = $newTask->getProject(); + if (null === $project) { + return; + } + $this->entityManager->wrapInTransaction(function () use ($newTask, $project): void { + $maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($project); $newTask->setNumber($maxNumber + 1); $this->entityManager->persist($newTask); $this->entityManager->flush(); diff --git a/src/Module/ProjectManagement/Infrastructure/Controller/TaskDocumentDownloadController.php b/src/Module/ProjectManagement/Infrastructure/Controller/TaskDocumentDownloadController.php index 9d8e2b7..fb705f5 100644 --- a/src/Module/ProjectManagement/Infrastructure/Controller/TaskDocumentDownloadController.php +++ b/src/Module/ProjectManagement/Infrastructure/Controller/TaskDocumentDownloadController.php @@ -10,11 +10,13 @@ use App\Module\Integration\Domain\Service\FileSource; use App\Module\ProjectManagement\Domain\Entity\TaskDocument; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -26,6 +28,7 @@ class TaskDocumentDownloadController extends AbstractController public function __construct( private readonly EntityManagerInterface $entityManager, private readonly FileSource $fileSource, + private readonly Security $security, private readonly string $uploadDir, ) {} @@ -39,6 +42,19 @@ class TaskDocumentDownloadController extends AbstractController throw new NotFoundHttpException('Document not found.'); } + $isAdmin = $this->security->isGranted('ROLE_ADMIN'); + $isClient = $this->security->isGranted('ROLE_CLIENT') && !$isAdmin; + if (!$isAdmin) { + if ($isClient) { + $ticket = $document->getClientTicket(); + if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) { + throw new AccessDeniedHttpException(); + } + } elseif (null === $document->getTask()) { + throw new AccessDeniedHttpException(); + } + } + $mimeType = $document->getMimeType() ?? 'application/octet-stream'; // Inline for images (except SVG) and PDFs, attachment for everything else. diff --git a/src/Module/TimeTracking/Infrastructure/Controller/TimeEntryExportController.php b/src/Module/TimeTracking/Infrastructure/Controller/TimeEntryExportController.php index 6808436..cd4894a 100644 --- a/src/Module/TimeTracking/Infrastructure/Controller/TimeEntryExportController.php +++ b/src/Module/TimeTracking/Infrastructure/Controller/TimeEntryExportController.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace App\Module\TimeTracking\Infrastructure\Controller; -use App\Module\Core\Domain\Entity\User; -use App\Module\ProjectManagement\Domain\Entity\Project; +use App\Module\Core\Domain\Repository\UserRepositoryInterface; +use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface; use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface; use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService; +use App\Shared\Domain\Contract\ProjectInterface; +use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; -use Doctrine\ORM\EntityManagerInterface; use Exception; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; @@ -25,7 +26,8 @@ class TimeEntryExportController extends AbstractController public function __construct( private readonly TimeEntryRepositoryInterface $timeEntryRepository, private readonly TimeEntryExportService $exportService, - private readonly EntityManagerInterface $entityManager, + private readonly ProjectRepositoryInterface $projectRepository, + private readonly UserRepositoryInterface $userRepository, private readonly Security $security, ) {} @@ -54,7 +56,7 @@ class TimeEntryExportController extends AbstractController // --- Users --- $users = null; if (!$this->security->isGranted('ROLE_ADMIN')) { - /** @var User $currentUser */ + /** @var UserInterface $currentUser */ $currentUser = $this->security->getUser(); $users = [$currentUser]; } else { @@ -64,7 +66,7 @@ class TimeEntryExportController extends AbstractController fn (int $id) => $id > 0, ); if ([] !== $userIds) { - $users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]); + $users = $this->userRepository->findByIds($userIds); } } @@ -72,7 +74,7 @@ class TimeEntryExportController extends AbstractController $clientId = $request->query->getInt('client'); $clientProjects = null; if ($clientId > 0) { - $clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]); + $clientProjects = $this->projectRepository->findBy(['client' => $clientId]); } // --- Projects --- @@ -84,13 +86,13 @@ class TimeEntryExportController extends AbstractController fn (int $id) => $id > 0, ); if ([] !== $projectIds) { - $projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]); + $projects = $this->projectRepository->findBy(['id' => $projectIds]); } // Merge: if both client and projects are set, intersect; if only client, use client projects if (null !== $clientProjects && null !== $projects) { - $clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects); - $projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true))); + $clientProjectIds = array_map(fn (ProjectInterface $p) => $p->getId(), $clientProjects); + $projects = array_values(array_filter($projects, fn (ProjectInterface $p) => in_array($p->getId(), $clientProjectIds, true))); if ([] === $projects) { $projects = null; // No matching projects — force empty result by using a dummy condition diff --git a/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php b/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php index 031776c..ebbbf8f 100644 --- a/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php +++ b/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\TimeTracking\Infrastructure\Export; use App\Module\TimeTracking\Domain\Entity\TimeEntry; +use App\Shared\Domain\Contract\ProjectInterface; use DateTimeImmutable; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -70,6 +71,7 @@ class TimeEntryExportService $task = $entry->getTask(); $taskLabel = ''; if (null !== $task) { + /** @var null|ProjectInterface $project */ $project = $task->getProject(); $code = $project?->getCode() ?? ''; $taskLabel = $code.'-'.$task->getNumber().' - '.$task->getTitle(); diff --git a/src/Shared/Domain/Contract/TaskInterface.php b/src/Shared/Domain/Contract/TaskInterface.php index 127b233..4742c4a 100644 --- a/src/Shared/Domain/Contract/TaskInterface.php +++ b/src/Shared/Domain/Contract/TaskInterface.php @@ -14,4 +14,6 @@ interface TaskInterface public function getNumber(): ?int; public function getTitle(): ?string; + + public function getProject(): ?ProjectInterface; } diff --git a/src/Shared/Infrastructure/Mcp/Serializer.php b/src/Shared/Infrastructure/Mcp/Serializer.php index f6660a0..74ff602 100644 --- a/src/Shared/Infrastructure/Mcp/Serializer.php +++ b/src/Shared/Infrastructure/Mcp/Serializer.php @@ -11,7 +11,6 @@ use App\Module\Core\Domain\Entity\User; use App\Module\Directory\Domain\Entity\Client; use App\Module\Directory\Domain\Entity\Prospect; use App\Module\ProjectManagement\Domain\Entity\Project; -use App\Module\ProjectManagement\Domain\Entity\Task; use App\Module\ProjectManagement\Domain\Entity\TaskDocument; use App\Module\ProjectManagement\Domain\Entity\TaskEffort; use App\Module\ProjectManagement\Domain\Entity\TaskGroup; @@ -19,6 +18,10 @@ 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\ProjectInterface; +use App\Shared\Domain\Contract\TaskInterface; +use App\Shared\Domain\Contract\TaskTagInterface; +use App\Shared\Domain\Contract\UserInterface; use Doctrine\Common\Collections\Collection; /** @@ -31,7 +34,7 @@ final class Serializer /** * @return array{id: ?int, code: ?string, name: ?string} */ - public static function projectRef(Project $project): array + public static function projectRef(ProjectInterface $project): array { return [ 'id' => $project->getId(), @@ -126,7 +129,7 @@ final class Serializer /** * @return null|array{id: ?int, username: ?string} */ - public static function user(?User $user): ?array + public static function user(?UserInterface $user): ?array { if (null === $user) { return null; @@ -139,13 +142,13 @@ final class Serializer } /** - * @param Collection $users + * @param Collection $users * * @return list */ public static function users(Collection $users): array { - return $users->map(fn (User $u) => [ + return $users->map(fn (UserInterface $u) => [ 'id' => $u->getId(), 'username' => $u->getUsername(), ])->toArray(); @@ -200,13 +203,13 @@ final class Serializer } /** - * @param Collection $tags + * @param Collection $tags * * @return list */ public static function tags(Collection $tags): array { - return $tags->map(fn (TaskTag $t) => [ + return $tags->map(fn (TaskTagInterface $t) => [ 'id' => $t->getId(), 'label' => $t->getLabel(), ])->toArray(); @@ -244,7 +247,7 @@ final class Serializer /** * @return null|array{id: ?int, number: ?int, title: ?string} */ - public static function taskRef(?Task $task): ?array + public static function taskRef(?TaskInterface $task): ?array { if (null === $task) { return null;