refactor(client-portal) : remove client portal feature entirely
- drop ClientPortal module, ClientTicket entity, ROLE_CLIENT and all couplings (Task, TaskDocument, User, Notification) back to an internal-only model - migration drops client_ticket / user_allowed_projects / related FK columns and removes leftover external client accounts (would otherwise be promoted to ROLE_USER) - remove client-portal frontend module, admin tickets tab, user portal section, portal nav item and portal/clientTicket i18n keys - fix directory nav icon (invalid mdi:contact-multiple-outline -> mdi:card-account-details-outline) - add 'make sync-permissions' target, wire it into install/db-reset and the prod deploy script
This commit is contained in:
@@ -9,9 +9,6 @@ use App\Module\Absence\Domain\Entity\AbsencePolicy;
|
||||
use App\Module\Absence\Domain\Entity\AbsenceRequest;
|
||||
use App\Module\Absence\Domain\Enum\AbsenceStatus;
|
||||
use App\Module\Absence\Domain\Enum\AbsenceType;
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
|
||||
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
|
||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Enum\ContractType;
|
||||
@@ -218,53 +215,6 @@ class AppFixtures extends Fixture
|
||||
$projectInterne->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Client portal users (ROLE_CLIENT) — linked to a client + allowed projects.
|
||||
$clientUserLiot = new User();
|
||||
$clientUserLiot->setUsername('client-liot');
|
||||
$clientUserLiot->setFirstName('Camille');
|
||||
$clientUserLiot->setLastName('LIOT');
|
||||
$clientUserLiot->setRoles(['ROLE_CLIENT']);
|
||||
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client-liot'));
|
||||
$clientUserLiot->setClient($clientLiot);
|
||||
$clientUserLiot->addAllowedProject($projectSirh);
|
||||
$manager->persist($clientUserLiot);
|
||||
|
||||
$clientUserAcme = new User();
|
||||
$clientUserAcme->setUsername('client-acme');
|
||||
$clientUserAcme->setFirstName('Sophie');
|
||||
$clientUserAcme->setLastName('ACME');
|
||||
$clientUserAcme->setRoles(['ROLE_CLIENT']);
|
||||
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client-acme'));
|
||||
$clientUserAcme->setClient($clientAcme);
|
||||
$clientUserAcme->addAllowedProject($projectCrm);
|
||||
$manager->persist($clientUserAcme);
|
||||
|
||||
// Demo client tickets.
|
||||
$ticketLiot = new ClientTicket();
|
||||
$ticketLiot->setNumber(1);
|
||||
$ticketLiot->setType(ClientTicketType::Bug);
|
||||
$ticketLiot->setTitle('Erreur lors de l\'export des congés');
|
||||
$ticketLiot->setDescription('L\'export PDF des congés échoue avec une erreur 500.');
|
||||
$ticketLiot->setUrl('https://app.example.com/sirh/conges');
|
||||
$ticketLiot->setStatus(ClientTicketStatus::New);
|
||||
$ticketLiot->setProject($projectSirh);
|
||||
$ticketLiot->setSubmittedBy($clientUserLiot);
|
||||
$ticketLiot->setCreatedAt(new DateTimeImmutable());
|
||||
$ticketLiot->setUpdatedAt(new DateTimeImmutable());
|
||||
$manager->persist($ticketLiot);
|
||||
|
||||
$ticketAcme = new ClientTicket();
|
||||
$ticketAcme->setNumber(1);
|
||||
$ticketAcme->setType(ClientTicketType::Improvement);
|
||||
$ticketAcme->setTitle('Ajouter un filtre par commercial');
|
||||
$ticketAcme->setDescription('Pouvoir filtrer la liste des opportunités par commercial assigné.');
|
||||
$ticketAcme->setStatus(ClientTicketStatus::InProgress);
|
||||
$ticketAcme->setProject($projectCrm);
|
||||
$ticketAcme->setSubmittedBy($clientUserAcme);
|
||||
$ticketAcme->setCreatedAt(new DateTimeImmutable());
|
||||
$ticketAcme->setUpdatedAt(new DateTimeImmutable());
|
||||
$manager->persist($ticketAcme);
|
||||
|
||||
// Task Efforts
|
||||
$effortS = new TaskEffort();
|
||||
$effortS->setLabel('S');
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
final class ClientPortalModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'client-portal';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Portail client';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions RBAC fin du Module ClientPortal.
|
||||
*
|
||||
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
|
||||
* reste pilotée par ROLE_CLIENT/ROLE_ADMIN sur les opérations.
|
||||
*
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'client-portal.tickets.view', 'label' => 'Voir les tickets client'],
|
||||
['code' => 'client-portal.tickets.manage', 'label' => 'Gérer les tickets client'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
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\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
|
||||
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
|
||||
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketNumberProcessor;
|
||||
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketProvider;
|
||||
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketStatusProcessor;
|
||||
use App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
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')",
|
||||
processor: ClientTicketStatusProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['client_ticket:read']],
|
||||
denormalizationContext: ['groups' => ['client_ticket:write']],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'status' => 'exact', 'submittedBy' => 'exact'])]
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientTicketRepository::class)]
|
||||
#[ORM\Table(name: 'client_ticket')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
|
||||
#[ORM\Index(name: 'idx_client_ticket_project', columns: ['project_id'])]
|
||||
#[ORM\Index(name: 'idx_client_ticket_submitted_by', columns: ['submitted_by_id'])]
|
||||
#[ORM\Index(name: 'idx_client_ticket_status_project', columns: ['status', 'project_id'])]
|
||||
class ClientTicket implements ClientTicketInterface, TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_ticket:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Incremental number, unique per project (formatted CT-XXX in the UI). */
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['client_ticket:read', 'task:read'])]
|
||||
private ?int $number = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketType::class)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
||||
#[Assert\NotNull]
|
||||
private ?ClientTicketType $type = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $description = null;
|
||||
|
||||
/** Displayed only when type = bug (page concerned by the bug). */
|
||||
#[ORM\Column(length: 1024, nullable: true)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketStatus::class)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
||||
private ClientTicketStatus $status = ClientTicketStatus::New;
|
||||
|
||||
/** Manager comment set when the status changes (mandatory when rejecting). */
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
||||
private ?string $statusComment = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ProjectInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'project_id', nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?ProjectInterface $project = null;
|
||||
|
||||
/** Client user who submitted the ticket. ON DELETE SET NULL — keep history. */
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'submitted_by_id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['client_ticket:read'])]
|
||||
private ?UserInterface $submittedBy = null;
|
||||
|
||||
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 getTypeEnum(): ?ClientTicketType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?ClientTicketType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type?->value ?? '';
|
||||
}
|
||||
|
||||
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 getStatusEnum(): ClientTicketStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(ClientTicketStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status->value;
|
||||
}
|
||||
|
||||
public function getStatusComment(): ?string
|
||||
{
|
||||
return $this->statusComment;
|
||||
}
|
||||
|
||||
public function setStatusComment(?string $statusComment): static
|
||||
{
|
||||
$this->statusComment = $statusComment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProject(): ?ProjectInterface
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function setProject(?ProjectInterface $project): static
|
||||
{
|
||||
$this->project = $project;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedBy(): ?UserInterface
|
||||
{
|
||||
return $this->submittedBy;
|
||||
}
|
||||
|
||||
public function setSubmittedBy(?UserInterface $submittedBy): static
|
||||
{
|
||||
$this->submittedBy = $submittedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Domain\Enum;
|
||||
|
||||
enum ClientTicketStatus: string
|
||||
{
|
||||
case New = 'new';
|
||||
case InProgress = 'in_progress';
|
||||
case Done = 'done';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::New => 'Nouveau',
|
||||
self::InProgress => 'En cours',
|
||||
self::Done => 'Terminé',
|
||||
self::Rejected => 'Rejeté',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a transition from this status to $target is allowed.
|
||||
*
|
||||
* All transitions are allowed except `done` -> `new` and `rejected` -> `new`.
|
||||
*/
|
||||
public function canTransitionTo(self $target): bool
|
||||
{
|
||||
if (self::New === $target && (self::Done === $this || self::Rejected === $this)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Domain\Enum;
|
||||
|
||||
enum ClientTicketType: string
|
||||
{
|
||||
case Bug = 'bug';
|
||||
case Improvement = 'improvement';
|
||||
case Other = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Bug => 'Bug',
|
||||
self::Improvement => 'Amélioration',
|
||||
self::Other => 'Autre',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Domain\Repository;
|
||||
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
|
||||
interface ClientTicketRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientTicket;
|
||||
|
||||
/**
|
||||
* Highest ticket number currently used on a given project, behind a
|
||||
* PostgreSQL advisory transaction lock so concurrent inserts serialize.
|
||||
* Returns 0 if the project has no ticket yet. Must run inside a transaction.
|
||||
*/
|
||||
public function findMaxNumberByProjectForUpdate(int $projectId): int;
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
|
||||
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles creation of a client ticket (POST).
|
||||
*
|
||||
* - Rejects users whose `client` is null (an admin inheriting ROLE_CLIENT via
|
||||
* the role hierarchy cannot create a ticket).
|
||||
* - Enforces that the target project belongs to the user's allowed projects.
|
||||
* - Sets submittedBy, status = new, timestamps.
|
||||
* - Generates the per-project incremental number behind an advisory lock so the
|
||||
* unique constraint `(project_id, number)` is never violated by concurrency.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientTicket, ClientTicket>
|
||||
*/
|
||||
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @param ProcessorInterface<ClientTicket, ClientTicket> $persistProcessor
|
||||
*/
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
private ClientTicketRepositoryInterface $repository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private NotifierInterface $notifier,
|
||||
private UserRepositoryInterface $userRepository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
|
||||
{
|
||||
assert($data instanceof ClientTicket);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
// An admin must not be able to create a ticket even though ROLE_ADMIN
|
||||
// inherits ROLE_CLIENT in the role hierarchy.
|
||||
if (null === $user->getClient()) {
|
||||
throw new AccessDeniedHttpException('Only client users can submit tickets.');
|
||||
}
|
||||
|
||||
$project = $data->getProject();
|
||||
if (!$project instanceof ProjectInterface) {
|
||||
throw new UnprocessableEntityHttpException('A project is required.');
|
||||
}
|
||||
|
||||
if (!$this->userMayAccessProject($user, $project)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to submit tickets on this project.');
|
||||
}
|
||||
|
||||
$data->setSubmittedBy($user);
|
||||
$data->setStatus(ClientTicketStatus::New);
|
||||
$data->setStatusComment(null);
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
$data->setCreatedAt($now);
|
||||
$data->setUpdatedAt($now);
|
||||
|
||||
$ticket = $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket {
|
||||
$maxNumber = $this->repository->findMaxNumberByProjectForUpdate((int) $project->getId());
|
||||
$data->setNumber($maxNumber + 1);
|
||||
|
||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
assert($result instanceof ClientTicket);
|
||||
|
||||
return $result;
|
||||
});
|
||||
|
||||
// Notify admins after the ticket is committed; never let a notification
|
||||
// failure roll back or break the creation.
|
||||
$this->notifyAdmins($ticket, $project);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
private function notifyAdmins(ClientTicket $ticket, ProjectInterface $project): void
|
||||
{
|
||||
try {
|
||||
$number = sprintf('CT-%03d', (int) $ticket->getNumber());
|
||||
$projectName = $project->getName() ?? '';
|
||||
$title = sprintf('Nouveau ticket client %s', $number);
|
||||
$message = '' !== $projectName
|
||||
? sprintf('« %s » — %s', (string) $ticket->getTitle(), $projectName)
|
||||
: sprintf('« %s »', (string) $ticket->getTitle());
|
||||
|
||||
foreach ($this->userRepository->findByRole('ROLE_ADMIN') as $admin) {
|
||||
$this->notifier->notify($admin, 'ticket_created', $title, $message, $ticket);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Tolerant: ticket creation must succeed even if notifications fail.
|
||||
}
|
||||
}
|
||||
|
||||
private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool
|
||||
{
|
||||
foreach ($user->getAllowedProjects() as $allowed) {
|
||||
if ($allowed->getId() === $project->getId()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Provider for ClientTicket read operations.
|
||||
*
|
||||
* - ROLE_ADMIN: no restriction.
|
||||
* - ROLE_CLIENT: only tickets the user submitted, and only on projects the
|
||||
* user is allowed to access (allowedProjects).
|
||||
*
|
||||
* @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 UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
$repo = $this->entityManager->getRepository(ClientTicket::class);
|
||||
|
||||
// Single item.
|
||||
if (isset($uriVariables['id'])) {
|
||||
$ticket = $repo->find($uriVariables['id']);
|
||||
if (null === $ticket) {
|
||||
return null;
|
||||
}
|
||||
if ($isAdmin) {
|
||||
return $ticket;
|
||||
}
|
||||
if ($ticket->getSubmittedBy() !== $user) {
|
||||
return null;
|
||||
}
|
||||
if (!$this->userMayAccessProject($user, $ticket->getProject())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
// Collection.
|
||||
$qb = $repo->createQueryBuilder('t')
|
||||
->orderBy('t.createdAt', 'DESC')
|
||||
;
|
||||
|
||||
if (!$isAdmin) {
|
||||
$qb->andWhere('t.submittedBy = :user')->setParameter('user', $user);
|
||||
|
||||
$allowedIds = $this->allowedProjectIds($user);
|
||||
if ([] === $allowedIds) {
|
||||
return [];
|
||||
}
|
||||
$qb->andWhere('IDENTITY(t.project) IN (:allowedProjects)')
|
||||
->setParameter('allowedProjects', $allowedIds)
|
||||
;
|
||||
}
|
||||
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (isset($filters['project'])) {
|
||||
$qb->andWhere('IDENTITY(t.project) = :project')
|
||||
->setParameter('project', self::extractId($filters['project']))
|
||||
;
|
||||
}
|
||||
if (isset($filters['status'])) {
|
||||
$qb->andWhere('t.status = :status')->setParameter('status', $filters['status']);
|
||||
}
|
||||
if ($isAdmin && isset($filters['submittedBy'])) {
|
||||
$qb->andWhere('t.submittedBy = :submittedBy')
|
||||
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private function userMayAccessProject(UserInterface $user, ?ProjectInterface $project): bool
|
||||
{
|
||||
if (null === $project) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($project->getId(), $this->allowedProjectIds($user), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function allowedProjectIds(UserInterface $user): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($user->getAllowedProjects() as $project) {
|
||||
$id = $project->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private static function extractId(string $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : (int) basename($value);
|
||||
}
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles status changes on a client ticket (PATCH, ROLE_ADMIN only).
|
||||
*
|
||||
* - Rejects the forbidden transitions `done` -> `new` and `rejected` -> `new`.
|
||||
* - Requires a statusComment when moving to the `rejected` status.
|
||||
* - Refreshes updatedAt.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientTicket, ClientTicket>
|
||||
*/
|
||||
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotifierInterface $notifier,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
|
||||
{
|
||||
assert($data instanceof ClientTicket);
|
||||
|
||||
$newStatus = $data->getStatusEnum();
|
||||
|
||||
$previous = $context['previous_data'] ?? null;
|
||||
$oldStatus = $previous instanceof ClientTicket ? $previous->getStatusEnum() : null;
|
||||
|
||||
if (null !== $oldStatus && !$oldStatus->canTransitionTo($newStatus)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Transition from "%s" to "%s" is not allowed.',
|
||||
$oldStatus->value,
|
||||
$newStatus->value,
|
||||
));
|
||||
}
|
||||
|
||||
if (ClientTicketStatus::Rejected === $newStatus && '' === trim((string) $data->getStatusComment())) {
|
||||
throw new UnprocessableEntityHttpException('A status comment is required to reject a ticket.');
|
||||
}
|
||||
|
||||
$data->setUpdatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Notify the submitter when the status actually changed.
|
||||
if ($oldStatus !== $newStatus) {
|
||||
$this->notifySubmitter($data, $newStatus);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function notifySubmitter(ClientTicket $ticket, ClientTicketStatus $newStatus): void
|
||||
{
|
||||
$submitter = $ticket->getSubmittedBy();
|
||||
if (!$submitter instanceof UserInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$number = sprintf('CT-%03d', (int) $ticket->getNumber());
|
||||
$title = sprintf('Ticket %s mis à jour', $number);
|
||||
$comment = trim((string) $ticket->getStatusComment());
|
||||
$message = '' !== $comment
|
||||
? sprintf('%s — %s', $newStatus->label(), $comment)
|
||||
: $newStatus->label();
|
||||
|
||||
$this->notifier->notify($submitter, 'ticket_status_changed', $title, $message, $ticket);
|
||||
} catch (Throwable) {
|
||||
// Tolerant: the status change must succeed even if notifications fail.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ClientPortal\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
|
||||
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientTicket>
|
||||
*/
|
||||
final class DoctrineClientTicketRepository extends ServiceEntityRepository implements ClientTicketRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientTicket::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientTicket
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findMaxNumberByProjectForUpdate(int $projectId): int
|
||||
{
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
// Use a PostgreSQL advisory lock (project ID as lock key) instead of
|
||||
// FOR UPDATE because FOR UPDATE is not allowed with aggregate functions
|
||||
// in PostgreSQL. The lock is held until the surrounding transaction ends.
|
||||
$conn->executeStatement(
|
||||
'SELECT pg_advisory_xact_lock(:project)',
|
||||
['project' => $projectId],
|
||||
);
|
||||
|
||||
$result = $conn->fetchOne(
|
||||
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
|
||||
['project' => $projectId],
|
||||
);
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\NotificationProvider;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -33,7 +32,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: DoctrineNotificationRepository::class)]
|
||||
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
|
||||
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
|
||||
#[ORM\Index(columns: ['related_ticket_id'], name: 'idx_notification_related_ticket')]
|
||||
class Notification
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -59,12 +57,6 @@ class Notification
|
||||
#[Groups(['notification:read'])]
|
||||
private ?string $message = null;
|
||||
|
||||
/** Optional link to the client ticket this notification relates to. */
|
||||
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'related_ticket_id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?ClientTicketInterface $relatedTicket = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['notification:read', 'notification:write'])]
|
||||
private bool $isRead = false;
|
||||
@@ -126,18 +118,6 @@ class Notification
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelatedTicket(): ?ClientTicketInterface
|
||||
{
|
||||
return $this->relatedTicket;
|
||||
}
|
||||
|
||||
public function setRelatedTicket(?ClientTicketInterface $relatedTicket): static
|
||||
{
|
||||
$this->relatedTicket = $relatedTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->isRead;
|
||||
|
||||
@@ -18,9 +18,7 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\LeaveProfileInterface;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -177,34 +175,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
#[Groups(['user:rbac:read', 'user:rbac:write'])]
|
||||
private Collection $directPermissions;
|
||||
|
||||
// --- Client portal fields ---
|
||||
|
||||
/** Client this user belongs to. null = internal user, set = client user. */
|
||||
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['me:read', 'user:read', 'user:write'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
/**
|
||||
* Projects a client user is allowed to access (a subset of the client's projects).
|
||||
*
|
||||
* @var Collection<int, ProjectInterface>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: ProjectInterface::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'user_allowed_projects',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
)]
|
||||
#[Groups(['me:read', 'user:read', 'user:write'])]
|
||||
private Collection $allowedProjects;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->rbacRoles = new ArrayCollection();
|
||||
$this->directPermissions = new ArrayCollection();
|
||||
$this->allowedProjects = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -258,11 +233,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
{
|
||||
$roles = $this->roles;
|
||||
|
||||
// A client user must NOT inherit ROLE_USER (which would grant access to
|
||||
// the internal application). Only non-client users get ROLE_USER.
|
||||
if (!in_array('ROLE_CLIENT', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
// Every authenticated user gets ROLE_USER.
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
@@ -486,42 +458,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
$this->directPermissions->removeElement($permission);
|
||||
}
|
||||
|
||||
public function getClient(): ?ClientInterface
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?ClientInterface $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ProjectInterface>
|
||||
*/
|
||||
public function getAllowedProjects(): Collection
|
||||
{
|
||||
return $this->allowedProjects;
|
||||
}
|
||||
|
||||
public function addAllowedProject(ProjectInterface $project): static
|
||||
{
|
||||
if (!$this->allowedProjects->contains($project)) {
|
||||
$this->allowedProjects->add($project);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAllowedProject(ProjectInterface $project): static
|
||||
{
|
||||
$this->allowedProjects->removeElement($project);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions effectives = union (rôles RBAC → permissions) ∪ (permissions directes), triée, dédupliquée.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Module\Core\Infrastructure;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Notification;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
@@ -20,14 +19,12 @@ final readonly class Notifier implements NotifierInterface
|
||||
string $type,
|
||||
string $title,
|
||||
string $message,
|
||||
?ClientTicketInterface $relatedTicket = null,
|
||||
): void {
|
||||
$notification = new Notification();
|
||||
$notification->setUser($user);
|
||||
$notification->setType($type);
|
||||
$notification->setTitle($title);
|
||||
$notification->setMessage($message);
|
||||
$notification->setRelatedTicket($relatedTicket);
|
||||
$notification->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->em->persist($notification);
|
||||
|
||||
@@ -19,7 +19,6 @@ use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarPr
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
|
||||
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\TaskInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
@@ -164,16 +163,6 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?TaskRecurrence $recurrence = null;
|
||||
|
||||
/**
|
||||
* Optional manual link to a client ticket. Exposed (number/type/status/title)
|
||||
* in task:read so the kanban can show the linked-ticket icon without giving
|
||||
* ROLE_USER access to the /api/client_tickets collection.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?ClientTicketInterface $clientTicket = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
@@ -452,18 +441,6 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicketInterface
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicketInterface $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateScheduledDates(ExecutionContextInterface $context): void
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ use ApiPlatform\Metadata\Post;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProvider;
|
||||
use App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -22,10 +21,10 @@ 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,
|
||||
),
|
||||
@@ -35,11 +34,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
denormalizationContext: ['groups' => ['task_document:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\EntityListeners([TaskDocumentListener::class])]
|
||||
// A document must be attached to either a task or a client ticket.
|
||||
#[ORM\Table(name: 'task_document')]
|
||||
class TaskDocument
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -49,16 +46,10 @@ class TaskDocument
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['task_document:read', 'task_document:write'])]
|
||||
private ?Task $task = null;
|
||||
|
||||
/** Client ticket this document is attached to (alternative to task). */
|
||||
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['task_document:read', 'task_document:write', 'client_ticket:read'])]
|
||||
private ?ClientTicketInterface $clientTicket = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $originalName = null;
|
||||
@@ -109,18 +100,6 @@ class TaskDocument
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicketInterface
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicketInterface $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOriginalName(): ?string
|
||||
{
|
||||
return $this->originalName;
|
||||
|
||||
+22
-91
@@ -14,8 +14,6 @@ use App\Module\Integration\Domain\Service\FileSource;
|
||||
use App\Module\Integration\Domain\Service\SharePathResolver;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Task;
|
||||
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
||||
use App\Shared\Domain\Contract\ClientTicketInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -77,12 +75,11 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
|
||||
{
|
||||
// Défense en profondeur : l'opération Post est déjà protégée par
|
||||
// ROLE_ADMIN ou ROLE_CLIENT, mais on re-vérifie ici pour que les deux
|
||||
// chemins (upload ET lien partage) restent sûrs si la configuration de
|
||||
// sécurité de l'opération venait à changer.
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_CLIENT')) {
|
||||
throw new AccessDeniedHttpException('Creating documents requires admin or client privileges.');
|
||||
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
|
||||
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
|
||||
// configuration de sécurité de l'opération venait à changer.
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
@@ -94,14 +91,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
// Deux modes de création : upload d'un fichier (multipart) ou lien vers un fichier du partage SMB (JSON).
|
||||
$sharePath = $this->extractSharePath($request);
|
||||
|
||||
// Sécurité : un utilisateur client ne peut PAS créer de lien vers le
|
||||
// partage SMB interne (référence de fichier arbitraire hors de son
|
||||
// périmètre) — seul le téléversement lui est permis. Le lien partage
|
||||
// reste réservé aux administrateurs.
|
||||
if (null !== $sharePath && !$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Les utilisateurs clients ne peuvent pas créer de lien vers le partage ; un téléversement est requis.');
|
||||
}
|
||||
|
||||
$document = null !== $sharePath
|
||||
? $this->createShareLink($request, $sharePath)
|
||||
: $this->createUpload($request);
|
||||
@@ -147,6 +136,8 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
|
||||
}
|
||||
|
||||
$task = $this->resolveTask($request->request->get('task', ''));
|
||||
|
||||
// Use server-detected MIME type (finfo), not the client-supplied one
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
|
||||
@@ -166,7 +157,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$document = new TaskDocument();
|
||||
$this->attachTarget($document, $request);
|
||||
$document->setTask($task);
|
||||
$document->setOriginalName($originalName);
|
||||
$document->setFileName($fileName);
|
||||
$document->setMimeType($mimeType);
|
||||
@@ -177,6 +168,15 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
|
||||
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
|
||||
{
|
||||
$taskIri = $request->request->get('task');
|
||||
|
||||
if (!is_string($taskIri) || '' === $taskIri) {
|
||||
$payload = json_decode($request->getContent() ?: '{}', true);
|
||||
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
|
||||
}
|
||||
|
||||
$task = $this->resolveTask((string) $taskIri);
|
||||
|
||||
try {
|
||||
$path = $this->pathResolver->normalizeRelative($rawSharePath);
|
||||
} catch (InvalidPathException) {
|
||||
@@ -198,7 +198,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$document = new TaskDocument();
|
||||
$this->attachTarget($document, $request);
|
||||
$document->setTask($task);
|
||||
$document->setOriginalName($entry->name);
|
||||
$document->setSharePath($path);
|
||||
$document->setMimeType($entry->mimeType);
|
||||
@@ -233,61 +233,12 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the document to a task OR a client ticket, enforcing per-role
|
||||
* access. Exactly one of the two targets must be provided.
|
||||
*
|
||||
* - ROLE_ADMIN may attach to any task or any client ticket.
|
||||
* - ROLE_CLIENT may only attach to a client ticket they submitted, and may
|
||||
* never attach to a task.
|
||||
*/
|
||||
private function attachTarget(TaskDocument $document, Request $request): void
|
||||
{
|
||||
$taskIri = $this->readField($request, 'task');
|
||||
$clientTicketIri = $this->readField($request, 'clientTicket');
|
||||
|
||||
if ('' === $taskIri && '' === $clientTicketIri) {
|
||||
throw new BadRequestHttpException('A task or a clientTicket IRI is required.');
|
||||
}
|
||||
if ('' !== $taskIri && '' !== $clientTicketIri) {
|
||||
throw new BadRequestHttpException('Provide either a task or a clientTicket, not both.');
|
||||
}
|
||||
|
||||
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
if ('' !== $clientTicketIri) {
|
||||
$document->setClientTicket($this->resolveClientTicket($clientTicketIri, $isClient));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isClient) {
|
||||
throw new AccessDeniedHttpException('Client users can only attach documents to a client ticket.');
|
||||
}
|
||||
|
||||
$document->setTask($this->resolveTask($taskIri));
|
||||
}
|
||||
|
||||
private function readField(Request $request, string $field): string
|
||||
{
|
||||
$value = $request->request->get($field);
|
||||
|
||||
if (is_string($value) && '' !== $value) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
|
||||
$payload = json_decode($request->getContent() ?: '{}', true);
|
||||
if (is_array($payload) && isset($payload[$field]) && is_string($payload[$field])) {
|
||||
return $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolveTask(string $taskIri): Task
|
||||
{
|
||||
if ('' === $taskIri) {
|
||||
throw new BadRequestHttpException('A task IRI is required.');
|
||||
}
|
||||
|
||||
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
|
||||
|
||||
if (null === $task) {
|
||||
@@ -296,24 +247,4 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
private function resolveClientTicket(string $ticketIri, bool $isClient): ClientTicketInterface
|
||||
{
|
||||
$ticket = $this->entityManager->getRepository(ClientTicketInterface::class)->find((int) basename($ticketIri));
|
||||
|
||||
if (null === $ticket) {
|
||||
throw new BadRequestHttpException('Client ticket not found.');
|
||||
}
|
||||
|
||||
if ($isClient) {
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
if ($ticket->getSubmittedBy() !== $user) {
|
||||
throw new AccessDeniedHttpException('You can only attach documents to your own tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-41
@@ -12,12 +12,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Provider for TaskDocument read operations.
|
||||
*
|
||||
* - ROLE_ADMIN: every document.
|
||||
* - ROLE_USER: documents attached to a task (task IS NOT NULL).
|
||||
* - ROLE_CLIENT: documents attached to a client ticket the user submitted.
|
||||
*
|
||||
* @implements ProviderInterface<TaskDocument>
|
||||
*/
|
||||
final readonly class TaskDocumentProvider implements ProviderInterface
|
||||
@@ -32,56 +26,25 @@ final readonly class TaskDocumentProvider implements ProviderInterface
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
$isClient = $this->security->isGranted('ROLE_CLIENT');
|
||||
|
||||
$repo = $this->entityManager->getRepository(TaskDocument::class);
|
||||
|
||||
// Single item.
|
||||
// Single item
|
||||
if (isset($uriVariables['id'])) {
|
||||
$document = $repo->find($uriVariables['id']);
|
||||
if (null === $document) {
|
||||
return null;
|
||||
}
|
||||
if ($isAdmin) {
|
||||
return $document;
|
||||
}
|
||||
if ($isClient) {
|
||||
$ticket = $document->getClientTicket();
|
||||
|
||||
return null !== $ticket && $ticket->getSubmittedBy() === $user ? $document : null;
|
||||
}
|
||||
|
||||
// ROLE_USER: task-linked documents only.
|
||||
return null !== $document->getTask() ? $document : null;
|
||||
return $repo->find($uriVariables['id']);
|
||||
}
|
||||
|
||||
// Collection.
|
||||
// Collection
|
||||
$qb = $repo->createQueryBuilder('d')
|
||||
->orderBy('d.id', 'DESC')
|
||||
;
|
||||
|
||||
if ($isClient && !$isAdmin) {
|
||||
$qb->innerJoin('d.clientTicket', 'ct')
|
||||
->andWhere('ct.submittedBy = :user')
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
} elseif (!$isAdmin) {
|
||||
// ROLE_USER: only documents attached to a task.
|
||||
$qb->andWhere('d.task IS NOT NULL');
|
||||
}
|
||||
|
||||
// Apply filters from query parameters
|
||||
$filters = $context['filters'] ?? [];
|
||||
if (isset($filters['task'])) {
|
||||
$qb->andWhere('d.task = :task')
|
||||
->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();
|
||||
}
|
||||
|
||||
-16
@@ -10,13 +10,11 @@ use App\Module\Integration\Domain\Service\FileSource;
|
||||
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -28,7 +26,6 @@ class TaskDocumentDownloadController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly FileSource $fileSource,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
@@ -42,19 +39,6 @@ class TaskDocumentDownloadController extends AbstractController
|
||||
throw new NotFoundHttpException('Document not found.');
|
||||
}
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$isAdmin;
|
||||
if (!$isAdmin) {
|
||||
if ($isClient) {
|
||||
$ticket = $document->getClientTicket();
|
||||
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
} elseif (null === $document->getTask()) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
}
|
||||
|
||||
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
|
||||
|
||||
// Inline for images (except SVG) and PDFs, attachment for everything else.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat de LECTURE d'un ticket client, consommé hors du module ClientPortal.
|
||||
*
|
||||
* Permet à ProjectManagement (Task, TaskDocument) de référencer un ticket
|
||||
* client sans dépendre directement de l'entité concrète du module ClientPortal.
|
||||
*/
|
||||
interface ClientTicketInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getNumber(): ?int;
|
||||
|
||||
public function getType(): string;
|
||||
|
||||
public function getStatus(): string;
|
||||
|
||||
public function getTitle(): ?string;
|
||||
}
|
||||
@@ -11,6 +11,5 @@ interface NotifierInterface
|
||||
string $type,
|
||||
string $title,
|
||||
string $message,
|
||||
?ClientTicketInterface $relatedTicket = null,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
* Contrat de LECTURE de l'identité, consommé hors du module Core.
|
||||
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
|
||||
@@ -31,16 +29,4 @@ interface UserInterface
|
||||
|
||||
/** @return list<string> */
|
||||
public function getEffectivePermissions(): array;
|
||||
|
||||
/**
|
||||
* Client this user belongs to, or null for an internal user.
|
||||
*/
|
||||
public function getClient(): ?ClientInterface;
|
||||
|
||||
/**
|
||||
* Projects a client user is allowed to access.
|
||||
*
|
||||
* @return Collection<int, ProjectInterface>
|
||||
*/
|
||||
public function getAllowedProjects(): Collection;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user