refactor(client-portal) : remove client portal feature entirely
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m11s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m17s

- 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:
Matthieu
2026-06-22 09:49:44 +02:00
parent 8a5b115ccd
commit a18e1f575f
55 changed files with 170 additions and 2599 deletions
-50
View File
@@ -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;
}
@@ -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);
}
}
@@ -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;
+2 -66
View File
@@ -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;
@@ -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;
}
}
@@ -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();
}
@@ -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;
}