feat(client-portal) : phase 1 foundations — ROLE_CLIENT hardening + ClientTicket (back)
LST-69 (3.2) phase 1. New ClientPortal module + security foundations for the client portal (spec docs/superpowers/specs/2026-03-15-client-portal-design.md). - Security: User::getRoles() no longer adds ROLE_USER to ROLE_CLIENT users; role_hierarchy ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]. Existing Task/Project/ Client/TimeEntry/metadata endpoints already required ROLE_USER -> a pure ROLE_CLIENT is walled off (verified: 403). - User (Core): client (ManyToOne ClientInterface, SET NULL) + allowedProjects (ManyToMany ProjectInterface). UserInterface extended (getClient/ getAllowedProjects). - New ClientTicket entity (module ClientPortal) + enums + repository + API with per-client isolation (ClientTicketProvider: own tickets ∩ allowedProjects), per-project numbering under advisory lock (rejects if user.client null), status transition rules. ClientTicketInterface contract for Task/TaskDocument. - TaskDocument generalized: task nullable + clientTicket (CASCADE) + CHECK; per-role access. Task.clientTicket exposed in task:read. - Additive migration; demo client fixtures. - Tenancy tests assert the isolation invariant (a client never sees another client's tickets) rather than brittle absolute counts (shared test DB). 178 tests green, mapping valid, cs-fixer clean.
This commit is contained in:
@@ -10,6 +10,9 @@ 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\Directory\Domain\Entity\Client;
|
||||
@@ -215,6 +218,53 @@ 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');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?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(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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;
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<?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\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;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
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);
|
||||
|
||||
return $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;
|
||||
});
|
||||
}
|
||||
|
||||
private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool
|
||||
{
|
||||
foreach ($user->getAllowedProjects() as $allowed) {
|
||||
if ($allowed->getId() === $project->getId()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?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 DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
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();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ 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;
|
||||
@@ -173,11 +175,34 @@ 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
|
||||
@@ -229,8 +254,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
$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';
|
||||
}
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
@@ -454,6 +484,42 @@ 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.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ 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;
|
||||
@@ -162,6 +163,16 @@ 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();
|
||||
@@ -440,6 +451,18 @@ 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,6 +14,7 @@ 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;
|
||||
@@ -21,10 +22,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
|
||||
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
|
||||
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 Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
|
||||
processor: TaskDocumentProcessor::class,
|
||||
deserialize: false,
|
||||
),
|
||||
@@ -34,9 +35,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
denormalizationContext: ['groups' => ['task_document:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => '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]
|
||||
@@ -46,10 +49,16 @@ class TaskDocument
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: true, 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;
|
||||
@@ -100,6 +109,18 @@ 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;
|
||||
|
||||
+82
-21
@@ -14,6 +14,8 @@ 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;
|
||||
@@ -75,11 +77,12 @@ 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, 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.');
|
||||
// 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.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
@@ -136,8 +139,6 @@ 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';
|
||||
@@ -157,7 +158,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$document = new TaskDocument();
|
||||
$document->setTask($task);
|
||||
$this->attachTarget($document, $request);
|
||||
$document->setOriginalName($originalName);
|
||||
$document->setFileName($fileName);
|
||||
$document->setMimeType($mimeType);
|
||||
@@ -168,15 +169,6 @@ 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 +190,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$document = new TaskDocument();
|
||||
$document->setTask($task);
|
||||
$this->attachTarget($document, $request);
|
||||
$document->setOriginalName($entry->name);
|
||||
$document->setSharePath($path);
|
||||
$document->setMimeType($entry->mimeType);
|
||||
@@ -233,12 +225,61 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveTask(string $taskIri): Task
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if ('' === $taskIri) {
|
||||
throw new BadRequestHttpException('A task IRI is required.');
|
||||
$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
|
||||
{
|
||||
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
|
||||
|
||||
if (null === $task) {
|
||||
@@ -247,4 +288,24 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-4
@@ -12,6 +12,12 @@ 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
|
||||
@@ -26,25 +32,56 @@ 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'])) {
|
||||
return $repo->find($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;
|
||||
}
|
||||
|
||||
// Collection
|
||||
// Collection.
|
||||
$qb = $repo->createQueryBuilder('d')
|
||||
->orderBy('d.id', 'DESC')
|
||||
;
|
||||
|
||||
// Apply filters from query parameters
|
||||
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');
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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.
|
||||
@@ -29,4 +31,16 @@ 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