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
@@ -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();
}