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
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
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 function preg_match;
use function sprintf;
/**
* Monthly paid-leave accrual. For each active employee it credits one twelfth
* of their yearly entitlement (prorated by work-time ratio) to the current
* reference-period balance. Idempotent per month thanks to lastAccruedMonth,
* and it seeds the initial balance when the period balance is first created.
*
* Intended to run on the 1st of each month (cron). Notifications are out of
* scope for now.
*/
#[AsCommand(
name: 'app:absences:accrue-leave',
description: 'Credit the monthly paid-leave accrual to every active employee',
)]
class AccrueLeaveCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('month', null, InputOption::VALUE_REQUIRED, 'Target month (YYYY-MM), defaults to the current month')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Compute and display without persisting')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$monthOpt = $input->getOption('month');
$dryRun = (bool) $input->getOption('dry-run');
try {
$firstDay = $monthOpt
? new DateTimeImmutable($monthOpt.'-01')
: new DateTimeImmutable('first day of this month');
} catch (Exception) {
$io->error('Invalid --month, expected format YYYY-MM.');
return Command::FAILURE;
}
$firstDay = $firstDay->setTime(0, 0);
$lastDay = $firstDay->modify('last day of this month');
$monthKey = $firstDay->format('Y-m');
$io->title(sprintf('Acquisition CP — %s%s', $monthKey, $dryRun ? ' (dry-run)' : ''));
$employees = $this->userRepository->findActiveEmployees($lastDay);
if ([] === $employees) {
$io->warning('Aucun salarié actif pour ce mois.');
return Command::SUCCESS;
}
$rows = [];
$accrued = 0;
$skipped = 0;
foreach ($employees as $user) {
$rate = ($user->getAnnualLeaveDays() / 12) * $user->getWorkTimeRatio();
$period = $this->balanceService->periodFor($user, AbsenceType::PaidLeave, $firstDay);
$balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period);
$isNew = null === $balance;
if ($isNew) {
$balance = $this->balanceService->getOrCreateBalance($user, AbsenceType::PaidLeave, $period);
// On a new period, the previous period's "en cours d'acquisition" (N)
// becomes this period's acquired (N-1). At roll-out (no prior balance)
// seed the configured initial balance instead.
$previousPeriod = self::previousPeriod($period);
$previousBalance = null !== $previousPeriod
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null;
$balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $user->getInitialLeaveBalance(),
);
}
if ($monthKey === $balance->getLastAccruedMonth()) {
++$skipped;
$rows[] = [$user->getUsername(), $period, number_format($balance->getAcquired(), 2), number_format($balance->getAcquiring(), 2), 'déjà fait'];
continue;
}
$balance->setAcquiring($balance->getAcquiring() + $rate);
$balance->setLastAccruedMonth($monthKey);
++$accrued;
$seeded = $isNew && (null !== self::previousPeriod($period) || $user->getInitialLeaveBalance() > 0);
$rows[] = [
$user->getUsername(),
$period,
number_format($balance->getAcquired(), 2),
number_format($balance->getAcquiring(), 2),
sprintf('+%s%s', number_format($rate, 2), $seeded && $balance->getAcquired() > 0 ? ' (N-1 reporté)' : ''),
];
}
if (!$dryRun) {
$this->entityManager->flush();
}
$io->table(['Salarié', 'Période', 'Acquis (N-1)', 'En cours (N)', 'Action'], $rows);
$io->success(sprintf('%d crédité(s), %d ignoré(s)%s.', $accrued, $skipped, $dryRun ? ' (dry-run, rien enregistré)' : ''));
return Command::SUCCESS;
}
/** Previous reference period for a "YYYY-YYYY" paid-leave period, or null. */
private static function previousPeriod(string $period): ?string
{
if (1 !== preg_match('/^(\d{4})-(\d{4})$/', $period, $m)) {
return null;
}
return sprintf('%d-%d', (int) $m[1] - 1, (int) $m[2] - 1);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Absence;
use App\Service\PublicHolidayProvider;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Exposes French public holidays so the front (calendar, date pickers) can
* display them — the dates are computed server-side in pure PHP.
*/
class PublicHolidayController extends AbstractController
{
public function __construct(
private readonly PublicHolidayProvider $holidayProvider,
) {}
#[Route('/api/public_holidays', name: 'public_holidays', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): JsonResponse
{
$fromRaw = (string) $request->query->get('from', '');
$toRaw = (string) $request->query->get('to', '');
if ('' !== $fromRaw && '' !== $toRaw) {
$fromYear = (int) new DateTimeImmutable($fromRaw)->format('Y');
$toYear = (int) new DateTimeImmutable($toRaw)->format('Y');
} else {
$fromYear = $toYear = (int) ($request->query->get('year') ?: date('Y'));
}
$holidays = [];
for ($year = $fromYear; $year <= $toYear; ++$year) {
$holidays += $this->holidayProvider->getHolidays($year);
}
return $this->json($holidays);
}
}
@@ -7,10 +7,8 @@ namespace App\Controller;
use App\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\ResponseHeaderBag;
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;
@@ -19,7 +17,6 @@ class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -33,14 +30,6 @@ class TaskDocumentDownloadController extends AbstractController
throw new NotFoundHttpException('Document not found.');
}
// ROLE_CLIENT can only download documents from their own tickets
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_USER')) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You do not have access to this document.');
}
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) {
+6 -94
View File
@@ -8,7 +8,6 @@ use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\ClientTicket;
use App\Entity\MailConfiguration;
use App\Entity\Project;
use App\Entity\Task;
@@ -593,94 +592,6 @@ class AppFixtures extends Fixture
$manager->persist($entry);
}
// =============================================
// Client Users
// =============================================
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// =============================================
// Client Tickets
// =============================================
$ticket1 = new ClientTicket();
$ticket1->setNumber(1);
$ticket1->setType('bug');
$ticket1->setTitle('Erreur 500 sur la page de login');
$ticket1->setDescription('Quand je clique sur "Se connecter" avec un mot de passe vide, j\'obtiens une page blanche avec une erreur 500.');
$ticket1->setUrl('https://sirh.liot.fr/login');
$ticket1->setStatus('new');
$ticket1->setProject($projectSirh);
$ticket1->setSubmittedBy($clientUserLiot);
$ticket1->setCreatedAt(new DateTimeImmutable('-3 days'));
$ticket1->setUpdatedAt(new DateTimeImmutable('-3 days'));
$manager->persist($ticket1);
$ticket2 = new ClientTicket();
$ticket2->setNumber(2);
$ticket2->setType('improvement');
$ticket2->setTitle('Ajouter un export PDF des fiches employés');
$ticket2->setDescription('Il serait utile de pouvoir exporter les fiches employés au format PDF pour les archiver.');
$ticket2->setStatus('in_progress');
$ticket2->setProject($projectSirh);
$ticket2->setSubmittedBy($clientUserLiot);
$ticket2->setCreatedAt(new DateTimeImmutable('-7 days'));
$ticket2->setUpdatedAt(new DateTimeImmutable('-2 days'));
$manager->persist($ticket2);
$ticket3 = new ClientTicket();
$ticket3->setNumber(3);
$ticket3->setType('other');
$ticket3->setTitle('Demande de formation sur le module congés');
$ticket3->setDescription('Notre équipe RH souhaiterait une formation sur le nouveau module de gestion des congés.');
$ticket3->setStatus('done');
$ticket3->setStatusComment('Formation planifiée le 20/03. Ticket clos.');
$ticket3->setProject($projectSirh);
$ticket3->setSubmittedBy($clientUserLiot);
$ticket3->setCreatedAt(new DateTimeImmutable('-14 days'));
$ticket3->setUpdatedAt(new DateTimeImmutable('-5 days'));
$manager->persist($ticket3);
$ticket4 = new ClientTicket();
$ticket4->setNumber(1);
$ticket4->setType('bug');
$ticket4->setTitle('Doublons dans la liste des contacts');
$ticket4->setDescription('Certains contacts apparaissent en double après l\'import CSV. Le problème semble lié aux accents dans les noms.');
$ticket4->setStatus('new');
$ticket4->setProject($projectCrm);
$ticket4->setSubmittedBy($clientUserAcme);
$ticket4->setCreatedAt(new DateTimeImmutable('-1 day'));
$ticket4->setUpdatedAt(new DateTimeImmutable('-1 day'));
$manager->persist($ticket4);
$ticket5 = new ClientTicket();
$ticket5->setNumber(2);
$ticket5->setType('improvement');
$ticket5->setTitle('Filtre par date sur le pipeline de vente');
$ticket5->setDescription('Pouvoir filtrer le pipeline de vente par période (mois, trimestre, année).');
$ticket5->setStatus('rejected');
$ticket5->setStatusComment('Cette fonctionnalité est déjà prévue dans la prochaine version. Pas besoin de ticket spécifique.');
$ticket5->setProject($projectCrm);
$ticket5->setSubmittedBy($clientUserAcme);
$ticket5->setCreatedAt(new DateTimeImmutable('-10 days'));
$ticket5->setUpdatedAt(new DateTimeImmutable('-8 days'));
$manager->persist($ticket5);
// Link a task to a client ticket
$task3->setClientTicket($ticket1);
// =============================================
// Zimbra Configuration
// =============================================
@@ -777,18 +688,19 @@ class AppFixtures extends Fixture
// Paid-leave balances for the current reference period (June 1st → May 31st)
$cpPeriod = '2025-2026';
$balanceData = [
// [user, acquired, taken, pending]
[$admin, 22.5, 5.0, 0.0],
[$userAlice, 18.0, 2.0, 5.0],
[$userBob, 14.0, 0.0, 0.0],
// [user, acquired (N-1), acquiring (N, en cours), taken, pending]
[$admin, 10.0, 22.5, 5.0, 0.0],
[$userAlice, 8.0, 18.0, 2.0, 5.0],
[$userBob, 0.0, 14.0, 0.0, 0.0],
];
foreach ($balanceData as [$bUser, $acquired, $taken, $pending]) {
foreach ($balanceData as [$bUser, $acquired, $acquiring, $taken, $pending]) {
$balance = new AbsenceBalance();
$balance->setUser($bUser);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod($cpPeriod);
$balance->setAcquired($acquired);
$balance->setAcquiring($acquiring);
$balance->setTaken($taken);
$balance->setPending($pending);
$manager->persist($balance);
-66
View File
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private Security $security,
) {}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Project::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
// Only restrict for ROLE_CLIENT users who are NOT admins
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$allowedProjectIds = $user->getAllowedProjects()->map(
fn (Project $project) => $project->getId(),
)->toArray();
if ([] === $allowedProjectIds) {
$queryBuilder->andWhere('1 = 0');
return;
}
$queryBuilder
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
->setParameter('allowed_project_ids', $allowedProjectIds)
;
}
}
+46 -1
View File
@@ -59,10 +59,16 @@ class AbsenceBalance
#[Groups(['absence_balance:read'])]
private ?string $period = null;
/** Days acquired during the *previous* reference period (Congés N-1): fully available to take. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquired = 0.0;
/** Days being accrued during the *current* reference period (Congés N): "en cours d'acquisition". */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquiring = 0.0;
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $taken = 0.0;
@@ -72,10 +78,25 @@ class AbsenceBalance
#[Groups(['absence_balance:read'])]
private float $pending = 0.0;
/** Last month (format YYYY-MM) for which the monthly accrual was applied. */
#[ORM\Column(length: 7, nullable: true)]
private ?string $lastAccruedMonth = null;
/** Total entitlement for the period, both finalized (N-1) and in-progress (N). */
#[Groups(['absence_balance:read'])]
public function getAcquiredTotal(): float
{
return $this->acquired + $this->acquiring;
}
/**
* Days the employee can still take: in this organisation the days being
* accrued (N) are posable too, so they count towards what is available.
*/
#[Groups(['absence_balance:read'])]
public function getAvailable(): float
{
return $this->acquired - $this->taken;
return $this->acquired + $this->acquiring - $this->taken;
}
#[Groups(['absence_balance:read'])]
@@ -137,6 +158,18 @@ class AbsenceBalance
return $this;
}
public function getAcquiring(): float
{
return $this->acquiring;
}
public function setAcquiring(float $acquiring): static
{
$this->acquiring = $acquiring;
return $this;
}
public function getTaken(): float
{
return $this->taken;
@@ -160,4 +193,16 @@ class AbsenceBalance
return $this;
}
public function getLastAccruedMonth(): ?string
{
return $this->lastAccruedMonth;
}
public function setLastAccruedMonth(?string $lastAccruedMonth): static
{
$this->lastAccruedMonth = $lastAccruedMonth;
return $this;
}
}
-282
View File
@@ -1,282 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ClientTicketRepository;
use App\State\ClientTicketNumberProcessor;
use App\State\ClientTicketProvider;
use App\State\ClientTicketStatusProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Get(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Post(
security: "is_granted('ROLE_CLIENT')",
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)",
processor: ClientTicketStatusProcessor::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['client_ticket:read']],
denormalizationContext: ['groups' => ['client_ticket:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
#[ORM\Table(name: 'client_ticket')]
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
class ClientTicket
{
public const string TYPE_BUG = 'bug';
public const string TYPE_IMPROVEMENT = 'improvement';
public const string TYPE_OTHER = 'other';
public const array TYPES = [
self::TYPE_BUG,
self::TYPE_IMPROVEMENT,
self::TYPE_OTHER,
];
public const string STATUS_NEW = 'new';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_DONE = 'done';
public const string STATUS_REJECTED = 'rejected';
public const array STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PROGRESS,
self::STATUS_DONE,
self::STATUS_REJECTED,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::TYPES)]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::STATUSES)]
private ?string $status = 'new';
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?Project $project = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?User $submittedBy = null;
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])]
#[Groups(['client_ticket:read'])]
private Collection $documents;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
}
-17
View File
@@ -56,11 +56,6 @@ class Notification
#[Groups(['notification:read'])]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['notification:read'])]
private ?ClientTicket $relatedTicket = null;
#[ORM\Column]
#[Groups(['notification:read', 'notification:write'])]
private bool $isRead = false;
@@ -122,18 +117,6 @@ class Notification
return $this;
}
public function getRelatedTicket(): ?ClientTicket
{
return $this->relatedTicket;
}
public function setRelatedTicket(?ClientTicket $relatedTicket): static
{
$this->relatedTicket = $relatedTicket;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
+2 -2
View File
@@ -25,8 +25,8 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],
-17
View File
@@ -124,11 +124,6 @@ class Task
#[Groups(['task:read'])]
private Collection $documents;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicket $clientTicket = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $scheduledStart = null;
@@ -342,18 +337,6 @@ class Task
return $this->documents;
}
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
public function getScheduledStart(): ?DateTimeImmutable
{
return $this->scheduledStart;
+12 -29
View File
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
@@ -41,42 +41,37 @@ class TaskDocument
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?ClientTicket $clientTicket = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?string $fileName = null;
#[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
#[Groups(['task_document:read', 'task:read'])]
private ?User $uploadedBy = null;
public function getId(): ?int
@@ -167,16 +162,4 @@ class TaskDocument
return $this;
}
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
}
-17
View File
@@ -85,11 +85,6 @@ class TimeEntry
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?Task $task = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?ClientTicket $clientTicket = null;
/** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
#[ORM\JoinTable(
@@ -194,18 +189,6 @@ class TimeEntry
return $this;
}
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
/** @return Collection<int, TaskTag> */
public function getTags(): Collection
{
+7 -58
View File
@@ -16,8 +16,6 @@ use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -50,11 +48,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
private ?string $username = null;
/** @var list<string> */
@@ -78,17 +76,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255, nullable: true)]
private ?string $avatarFileName = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?Client $client = null;
/** @var Collection<int, Project> */
#[ORM\ManyToMany(targetEntity: Project::class)]
#[ORM\JoinTable(name: 'user_allowed_projects')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $allowedProjects;
// --- HR / absence management fields ---
/** Whether this user is an employee subject to absence management. */
@@ -139,8 +126,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->allowedProjects = new ArrayCollection();
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
@@ -168,11 +154,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
@@ -209,40 +192,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
/** @return Collection<int, Project> */
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(Project $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(Project $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
public function getApiToken(): ?string
{
return $this->apiToken;
@@ -267,7 +216,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
@@ -294,7 +243,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->plainPassword = null;
}
public function isEmployee(): bool
public function getIsEmployee(): bool
{
return $this->isEmployee;
}
+10 -28
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Entity\ClientTicket;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskDocument;
@@ -253,39 +252,22 @@ final class Serializer
];
}
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function clientTicketRef(?ClientTicket $ticket): ?array
{
if (null === $ticket) {
return null;
}
return [
'id' => $ticket->getId(),
'number' => $ticket->getNumber(),
'title' => $ticket->getTitle(),
];
}
/**
* @return array<string, mixed>
*/
public static function timeEntry(TimeEntry $entry): array
{
return [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()),
'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
'tags' => self::tags($entry->getTags()),
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()),
];
}
@@ -6,7 +6,6 @@ namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
@@ -31,7 +30,6 @@ class CreateTimeEntryTool
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly Security $security,
) {}
@@ -44,7 +42,6 @@ class CreateTimeEntryTool
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
?int $clientTicketId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -90,13 +87,6 @@ class CreateTimeEntryTool
}
$entry->setTask($task);
}
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) {
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
@@ -23,7 +23,6 @@ class ListTimeEntriesTool
?int $userId = null,
?int $projectId = null,
?int $taskId = null,
?int $clientTicketId = null,
?string $startDate = null,
?string $endDate = null,
int $limit = 100,
@@ -39,7 +38,6 @@ class ListTimeEntriesTool
->leftJoin('te.project', 'p')->addSelect('p')
->leftJoin('te.task', 't')->addSelect('t')
->leftJoin('te.tags', 'tg')->addSelect('tg')
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
->orderBy('te.startedAt', 'DESC')
->setMaxResults($limit)
;
@@ -53,9 +51,6 @@ class ListTimeEntriesTool
if (null !== $taskId) {
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
}
if (null !== $clientTicketId) {
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
}
if (null !== $startDate) {
$qb->andWhere('te.startedAt >= :startDate')
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
@@ -27,7 +26,6 @@ class UpdateTimeEntryTool
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -41,7 +39,6 @@ class UpdateTimeEntryTool
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
?int $clientTicketId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -79,13 +76,6 @@ class UpdateTimeEntryTool
}
$entry->setTask($task);
}
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) {
foreach ($entry->getTags()->toArray() as $existingTag) {
$entry->removeTag($existingTag);
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ClientTicket;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
class ClientTicketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
/**
* Returns the max ticket number for a project, using an advisory lock
* to prevent race conditions when creating tickets concurrently.
*/
public function findMaxNumberByProjectForUpdate(Project $project): int
{
$conn = $this->getEntityManager()->getConnection();
// Use PostgreSQL advisory lock instead of FOR UPDATE
// because FOR UPDATE is not allowed with aggregate functions in PostgreSQL.
// Offset by 1000000 to avoid collision with task locks on the same project ID.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:lockKey)',
['lockKey' => $project->getId() + 1000000],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
['project' => $project->getId()],
);
return (int) $result;
}
}
+21
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -38,4 +39,24 @@ class UserRepository extends ServiceEntityRepository
->getResult()
;
}
/**
* Employees active on the given date (hired on/before it, not yet left).
*
* @return User[]
*/
public function findActiveEmployees(DateTimeInterface $date): array
{
$dateStr = $date->format('Y-m-d');
return $this->createQueryBuilder('u')
->where('u.isEmployee = true')
->andWhere('u.hireDate IS NULL OR u.hireDate <= :date')
->andWhere('u.endDate IS NULL OR u.endDate >= :date')
->setParameter('date', $dateStr)
->orderBy('u.username', 'ASC')
->getQuery()
->getResult()
;
}
}
-5
View File
@@ -18,7 +18,6 @@ final readonly class MailAccessChecker
/**
* Verifie que l'utilisateur courant peut acceder aux endpoints mail.
* Autorise : ROLE_USER, ROLE_ADMIN.
* Refuse : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifie.
*
* @throws AccessDeniedException
*/
@@ -30,10 +29,6 @@ final readonly class MailAccessChecker
$roles = $user->getRoles();
if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
throw new AccessDeniedException('Mail not accessible to clients');
}
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
throw new AccessDeniedException('ROLE_USER required');
}
-74
View File
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ClientTicket;
use App\Entity\Notification;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
) {}
/**
* Notify all ROLE_ADMIN users that a new ticket was created.
*/
public function createForTicketCreated(ClientTicket $ticket): void
{
$admins = $this->userRepository->findByRole('ROLE_ADMIN');
$number = sprintf('CT-%03d', $ticket->getNumber());
$projectName = $ticket->getProject()?->getName() ?? '';
foreach ($admins as $admin) {
$notification = new Notification();
$notification->setUser($admin);
$notification->setType('ticket_created');
$notification->setTitle('Nouveau ticket client '.$number);
$notification->setMessage($ticket->getTitle().' — '.$projectName);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
/**
* Notify the ticket submitter that the status has changed.
*/
public function createForStatusChange(ClientTicket $ticket): void
{
$submittedBy = $ticket->getSubmittedBy();
if (null === $submittedBy) {
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusComment = $ticket->getStatusComment();
$message = 'Nouveau statut : '.$ticket->getStatus();
if (null !== $statusComment && '' !== $statusComment) {
$message .= ' — '.$statusComment;
}
$notification = new Notification();
$notification->setUser($submittedBy);
$notification->setType('ticket_status_changed');
$notification->setTitle('Ticket '.$number.' mis à jour');
$notification->setMessage($message);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
}
-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();
}