feat(absences) : avancement module absences + suppression du portail client

Deux lots regroupés sur la branche feat/absence-management.

Suppression complète du portail client :
- retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER
- supprime l'entité ClientTicket (+ repo, states, relations), User.client et
  User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc
  ROLE_CLIENT de MailAccessChecker
- front : pages /portal, layout portal, composants client-ticket/,
  AdminClientTicketTab, services/dto/i18n/docs associés
- fixtures : retire les users client-liot / client-acme
- migration Version20260522110000 (drop client_ticket, user_allowed_projects,
  colonnes liées ; task_document.task_id -> NOT NULL)
- tests : retire les cas obsolètes testant le blocage des clients sur le mail

Module gestion des absences (WIP) :
- entités / migrations (Version20260521160000, Version20260522090000)
- pages absences.vue / team-absences.vue, composants frontend/components/absence/
- services front, AccrueLeaveCommand, PublicHolidayController

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 11:31:31 +02:00
parent de98924fd3
commit 2a0b202d32
109 changed files with 3918 additions and 3656 deletions
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use App\Entity\User;
use App\Repository\ClientTicketRepository;
use App\Service\NotificationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private ClientTicketRepository $clientTicketRepository,
private NotificationService $notificationService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$user = $this->security->getUser();
assert($user instanceof User);
$project = $data->getProject();
if (null === $project) {
throw new BadRequestHttpException('Project is required.');
}
// Admins can create tickets on any project; clients only on allowed projects
if (!$this->security->isGranted('ROLE_ADMIN')) {
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can create tickets.');
}
if (!$user->getAllowedProjects()->contains($project)) {
throw new AccessDeniedHttpException('You do not have access to this project.');
}
}
$now = new DateTimeImmutable();
$maxNumber = $this->clientTicketRepository->findMaxNumberByProjectForUpdate($project);
$data->setNumber($maxNumber + 1);
$data->setSubmittedBy($user);
$data->setStatus('new');
$data->setCreatedAt($now);
$data->setUpdatedAt($now);
$this->entityManager->persist($data);
$this->entityManager->flush();
$this->notificationService->createForTicketCreated($data);
return $data;
}
}
-80
View File
@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\ClientTicket;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<ClientTicket>
*/
final readonly class ClientTicketProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ClientTicket|null
{
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(ClientTicket::class);
// Single item
if (isset($uriVariables['id'])) {
$ticket = $repo->find($uriVariables['id']);
if (null === $ticket) {
return null;
}
if (!$this->security->isGranted('ROLE_ADMIN') && $ticket->getSubmittedBy() !== $user) {
return null;
}
return $ticket;
}
// Collection with manual filtering
$qb = $repo->createQueryBuilder('ct')
->orderBy('ct.createdAt', 'DESC')
;
// ROLE_CLIENT: only own tickets
if (!$this->security->isGranted('ROLE_ADMIN')) {
$qb->andWhere('ct.submittedBy = :user')->setParameter('user', $user);
}
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$qb->andWhere('ct.project = :project')
->setParameter('project', self::extractId($filters['project']))
;
}
if (isset($filters['status'])) {
$qb->andWhere('ct.status = :status')->setParameter('status', $filters['status']);
}
if (isset($filters['submittedBy']) && $this->security->isGranted('ROLE_ADMIN')) {
$qb->andWhere('ct.submittedBy = :submittedBy')
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
;
}
return $qb->getQuery()->getResult();
}
/**
* Extract an entity ID from a value that may be a numeric ID or an IRI string.
*/
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use App\Service\NotificationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
{
private const FORBIDDEN_TRANSITIONS = [
'done' => ['new'],
'rejected' => ['new'],
];
public function __construct(
private EntityManagerInterface $entityManager,
private NotificationService $notificationService,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$originalData = $context['previous_data'] ?? null;
$statusChanged = false;
if ($originalData instanceof ClientTicket) {
// ROLE_CLIENT: can only edit content fields, not status
if (!$this->security->isGranted('ROLE_ADMIN')) {
$data->setStatus($originalData->getStatus());
$data->setStatusComment($originalData->getStatusComment());
}
$oldStatus = $originalData->getStatus();
$newStatus = $data->getStatus();
if ($oldStatus !== $newStatus) {
$statusChanged = true;
$forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? [];
if (in_array($newStatus, $forbidden, true)) {
throw new BadRequestHttpException(sprintf('Transition from "%s" to "%s" is not allowed.', $oldStatus, $newStatus));
}
if ('rejected' === $newStatus && (null === $data->getStatusComment() || '' === trim($data->getStatusComment()))) {
throw new BadRequestHttpException('A comment is required when rejecting a ticket.');
}
}
}
$data->setUpdatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
if ($statusChanged) {
$this->notificationService->createForStatusChange($data);
}
return $data;
}
}
+6 -32
View File
@@ -6,14 +6,12 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use App\Entity\Task;
use App\Entity\TaskDocument;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
@@ -81,39 +79,16 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$taskIri = $request->request->get('task', '');
$clientTicketIri = $request->request->get('clientTicket', '');
$taskIri = $request->request->get('task', '');
if ('' === $taskIri && '' === $clientTicketIri) {
throw new BadRequestHttpException('Either task or clientTicket IRI is required.');
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
}
$task = null;
$clientTicket = null;
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if ('' !== $taskIri) {
// ROLE_CLIENT (without ROLE_ADMIN) cannot upload documents directly to tasks
if ($this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Clients can only upload documents to client tickets.');
}
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
}
if ('' !== $clientTicketIri) {
$clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find((int) basename($clientTicketIri));
if (null === $clientTicket) {
throw new BadRequestHttpException('Client ticket not found.');
}
if (!$this->security->isGranted('ROLE_ADMIN') && $clientTicket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You can only upload documents to your own tickets.');
}
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
// Use server-detected MIME type (finfo), not the client-supplied one
@@ -137,7 +112,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
$document = new TaskDocument();
$document->setTask($task);
$document->setClientTicket($clientTicket);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
+2 -27
View File
@@ -26,24 +26,11 @@ final readonly class TaskDocumentProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(TaskDocument::class);
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item
if (isset($uriVariables['id'])) {
$document = $repo->find($uriVariables['id']);
if (null === $document) {
return null;
}
if ($isClient) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $user) {
return null;
}
}
return $document;
return $repo->find($uriVariables['id']);
}
// Collection
@@ -51,13 +38,6 @@ final readonly class TaskDocumentProvider implements ProviderInterface
->orderBy('d.id', 'DESC')
;
if ($isClient) {
$qb->innerJoin('d.clientTicket', 'ct')
->andWhere('ct.submittedBy = :user')
->setParameter('user', $user)
;
}
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
@@ -65,11 +45,6 @@ final readonly class TaskDocumentProvider implements ProviderInterface
->setParameter('task', self::extractId($filters['task']))
;
}
if (isset($filters['clientTicket'])) {
$qb->andWhere('d.clientTicket = :clientTicket')
->setParameter('clientTicket', self::extractId($filters['clientTicket']))
;
}
return $qb->getQuery()->getResult();
}