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:
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user