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:
Matthieu
2026-06-21 00:46:26 +02:00
parent f4ffc02028
commit 808a290845
24 changed files with 1337 additions and 33 deletions
+50
View File
@@ -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');