47 KiB
Client Portal Phase 1 — Foundations
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Lay the backend and frontend foundations for the Client Portal feature: secure existing endpoints against ROLE_CLIENT, create the ClientTicket entity with full CRUD API, extend User with client/project assignments, generalize TaskDocument for ticket attachments, and update the admin user management form.
Architecture: New ROLE_CLIENT role with isolated access. ClientTicket is a separate entity from Task with its own lifecycle. TaskDocument is generalized to support both tasks and tickets. Provider-based row-level security ensures clients only see their own tickets on their allowed projects.
Tech Stack: PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript
Spec: docs/superpowers/specs/2026-03-15-client-portal-design.md
Chunk 1: Security Hardening (Prerequisite)
Task 1: Fix User::getRoles() to exclude ROLE_USER for client users
- Modify
src/Entity/User.php— ChangegetRoles()at line 96 so thatROLE_USERis NOT added when the user hasROLE_CLIENT:
Replace the existing method (lines 95-102):
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
With:
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
- Commit:
git add src/Entity/User.php
git commit -m "fix(security) : exclude ROLE_USER from ROLE_CLIENT users in getRoles()"
Task 2: Add security on GetCollection/Get for Task and Project
- Modify
src/Entity/Task.php— Addsecurityto GetCollection (line 25) and Get (line 26). Replace:
new GetCollection(paginationEnabled: false),
new Get(),
With:
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/Project.php— Addsecurityto GetCollection (line 23) and Get (line 24). Replace:
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Commit:
git add src/Entity/Task.php src/Entity/Project.php
git commit -m "fix(security) : add ROLE_USER security on Task and Project read operations"
Task 3: Add security on GetCollection/Get for Client, TaskStatus, TaskEffort, TaskPriority
- Modify
src/Entity/Client.php— Replace (lines 21-22):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TaskStatus.php— Replace (lines 19-20):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TaskEffort.php— Replace (lines 19-20):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TaskPriority.php— Replace (lines 19-20):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Commit:
git add src/Entity/Client.php src/Entity/TaskStatus.php src/Entity/TaskEffort.php src/Entity/TaskPriority.php
git commit -m "fix(security) : add ROLE_USER security on Client, TaskStatus, TaskEffort, TaskPriority read operations"
Task 4: Add security on GetCollection/Get for TaskTag, TaskGroup, TimeEntry, TaskDocument
- Modify
src/Entity/TaskTag.php— Replace (lines 19-20):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TaskGroup.php— Replace (lines 21-22):
new GetCollection(),
new Get(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TimeEntry.php— Replace the first GetCollection (line 27) and Get (line 35):
new GetCollection(),
With:
new GetCollection(security: "is_granted('ROLE_USER')"),
And:
new Get(),
With:
new Get(security: "is_granted('ROLE_USER')"),
- Modify
src/Entity/TaskDocument.php— Replace (lines 22-23):
new GetCollection(paginationEnabled: false),
new Get(),
With:
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
- Commit:
git add src/Entity/TaskTag.php src/Entity/TaskGroup.php src/Entity/TimeEntry.php src/Entity/TaskDocument.php
git commit -m "fix(security) : add ROLE_USER security on TaskTag, TaskGroup, TimeEntry, TaskDocument read operations"
Task 5: Add role hierarchy to security config
- Modify
config/packages/security.yaml— Add role hierarchy after thepassword_hashersblock (after line 4). Replace:
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
With:
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
- Commit:
git add config/packages/security.yaml
git commit -m "feat(security) : add role hierarchy with ROLE_ADMIN inheriting ROLE_USER and ROLE_CLIENT"
Chunk 2: Entity Modifications
Task 6: Extend User entity with client and allowedProjects fields
- Modify
src/Entity/User.php— Add two imports after the existingusestatements (after line 20):
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
- Add the
clientandallowedProjectsproperties after the$passwordproperty (after line 63):
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?Client $client = null;
/** @var Collection<int, Project> */
#[ORM\ManyToMany(targetEntity: Project::class)]
#[ORM\JoinTable(name: 'user_allowed_projects')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $allowedProjects;
- Update the constructor (line 68-71). Replace:
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
With:
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->allowedProjects = new ArrayCollection();
}
- Add getters and setters before the
eraseCredentials()method (before line 136):
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
/** @return Collection<int, Project> */
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(Project $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(Project $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
- Add
client_ticket:readto User'sidandusernameGroups so that thesubmittedByrelation on ClientTicket embeds user data instead of a plain IRI. Find the existing$idand$usernameproperties and update their Groups:
Replace the existing $id Groups:
#[Groups(['user:list', 'me:read'])]
private ?int $id = null;
With:
#[Groups(['user:list', 'me:read', 'client_ticket:read'])]
private ?int $id = null;
Replace the existing $username Groups:
#[Groups(['user:list', 'me:read'])]
private ?string $username = null;
With:
#[Groups(['user:list', 'me:read', 'client_ticket:read'])]
private ?string $username = null;
- Commit:
git add src/Entity/User.php
git commit -m "feat(entity) : add client and allowedProjects fields to User entity"
Task 7: Create ClientTicket entity
- Create
src/Entity/ClientTicket.phpwith the following complete content:
<?php
declare(strict_types=1);
namespace App\Entity;
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\Repository\ClientTicketRepository;
use App\State\ClientTicketNumberProcessor;
use App\State\ClientTicketProvider;
use App\State\ClientTicketStatusProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[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'],
)]
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
#[ORM\Table(
uniqueConstraints: [
new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']),
],
)]
class ClientTicket
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $description = null;
#[ORM\Column(length: 2048, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $url = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $status = 'new';
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?Project $project = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?User $submittedBy = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $updatedAt = null;
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])]
#[Groups(['client_ticket:read'])]
private Collection $documents;
public function __construct()
{
$this->documents = new ArrayCollection();
}
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 getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
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 getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
}
- Commit:
git add src/Entity/ClientTicket.php
git commit -m "feat(entity) : create ClientTicket entity with API Platform operations"
Task 8: Add clientTicket field to Task entity
- Modify
src/Entity/Task.php— Add theclientTicketproperty after the$documentsproperty (after line 105):
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicket $clientTicket = null;
- Add getter and setter at the end of the class (before the closing
}):
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
- Commit:
git add src/Entity/Task.php
git commit -m "feat(entity) : add clientTicket relation to Task entity"
Task 9: Generalize TaskDocument entity for client tickets
- Modify
src/Entity/TaskDocument.php— Maketasknullable. Replace line 47:
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
With:
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
- Add
clientTicketproperty after the$taskproperty (after line 49):
#[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?ClientTicket $clientTicket = null;
- Add the
clientTicketApiFilter — Replace the existing ApiFilter line (line 35):
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
With:
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
- Update the Post operation security to also allow ROLE_CLIENT. Replace (line 24-28):
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
With:
new Post(
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
- Add
client_ticket:readto TaskDocument's serialization Groups so that thedocumentsrelation on ClientTicket embeds document data instead of plain IRIs. Find the existing Groups on the following properties and add'client_ticket:read':$id— add'client_ticket:read'$originalName— add'client_ticket:read'$fileName— add'client_ticket:read'$mimeType— add'client_ticket:read'$size— add'client_ticket:read'$createdAt— add'client_ticket:read'$uploadedBy— add'client_ticket:read'
For example, replace:
#[Groups(['task_document:read'])]
private ?int $id = null;
With:
#[Groups(['task_document:read', 'client_ticket:read'])]
private ?int $id = null;
Apply the same pattern to originalName, fileName, mimeType, size, createdAt, and uploadedBy.
- Add getter and setter for clientTicket after the
setTask()method (after line 91):
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
- Commit:
git add src/Entity/TaskDocument.php
git commit -m "feat(entity) : generalize TaskDocument to support clientTicket attachments"
Chunk 3: Migration & Repository
Task 10: Generate Doctrine migration
- Run the migration diff command:
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
Expected output: Generated new migration class to "migrations/VersionXXXXXXXXXXXXXX.php"
-
Review the generated migration file — Open the file at
migrations/VersionXXXXXXXXXXXXXX.php(the filename will be timestamped). Verify it contains:CREATE TABLE client_ticketwith columns:id,number,type,title,description,url,status,status_comment,project_id,submitted_by_id,created_at,updated_atCREATE TABLE user_allowed_projectswith columns:user_id,project_idALTER TABLE "user" ADD client_id(nullable FK)ALTER TABLE task ADD client_ticket_id(nullable FK)ALTER TABLE task_documentmakingtask_idnullable and addingclient_ticket_id- Unique constraint on
(project_id, number)forclient_ticket
-
Add CHECK constraint to the migration — In the
up()method, add this line after thetask_documentalterations:
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_document_owner CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)');
And in the down() method, add before the other task_document reversals:
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT IF EXISTS chk_document_owner');
- Commit:
git add migrations/
git commit -m "feat(migration) : add ClientTicket table, User client fields, Task clientTicket, generalize TaskDocument"
Task 11: Run migration
- Execute the migration:
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
Expected output: migration applied successfully, no errors.
Task 12: Create ClientTicketRepository
- Create
src/Repository/ClientTicketRepository.phpwith the following complete content:
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ClientTicket;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
class ClientTicketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
public function findNextNumberForProject(Project $project): int
{
$result = $this->createQueryBuilder('ct')
->select('MAX(ct.number)')
->where('ct.project = :project')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult()
;
return ((int) ($result ?? 0)) + 1;
}
}
- Commit:
git add src/Repository/ClientTicketRepository.php
git commit -m "feat(repository) : create ClientTicketRepository with findNextNumberForProject"
Chunk 4: State Providers & Processors
Task 13: Create ClientTicketNumberProcessor (POST)
- Create
src/State/ClientTicketNumberProcessor.phpwith the following complete content:
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use App\Entity\User;
use App\Repository\ClientTicketRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private ClientTicketRepository $clientTicketRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
/** @var User $user */
$user = $this->security->getUser();
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can create tickets.');
}
$project = $data->getProject();
if (null === $project) {
throw new BadRequestHttpException('Project is required.');
}
if (!$user->getAllowedProjects()->contains($project)) {
throw new AccessDeniedHttpException('You do not have access to this project.');
}
$nextNumber = $this->clientTicketRepository->findNextNumberForProject($project);
$data->setNumber($nextNumber);
$data->setSubmittedBy($user);
$data->setStatus('new');
$data->setCreatedAt(new \DateTimeImmutable());
$data->setUpdatedAt(new \DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}
- Commit:
git add src/State/ClientTicketNumberProcessor.php
git commit -m "feat(state) : create ClientTicketNumberProcessor for auto-numbering and validation"
Task 14: Create ClientTicketStatusProcessor (PATCH)
- Create
src/State/ClientTicketStatusProcessor.phpwith the following complete content:
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
{
/** @var array<string, list<string>> */
private const FORBIDDEN_TRANSITIONS = [
'done' => ['new'],
'rejected' => ['new'],
];
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$originalData = $context['previous_data'] ?? null;
if ($originalData instanceof ClientTicket) {
$oldStatus = $originalData->getStatus();
$newStatus = $data->getStatus();
if ($oldStatus !== $newStatus) {
$forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? [];
if (in_array($newStatus, $forbidden, true)) {
throw new BadRequestHttpException(sprintf('Transition from "%s" to "%s" is not allowed.', $oldStatus, $newStatus));
}
if ('rejected' === $newStatus && (null === $data->getStatusComment() || '' === trim($data->getStatusComment()))) {
throw new BadRequestHttpException('A comment is required when rejecting a ticket.');
}
}
}
$data->setUpdatedAt(new \DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}
- Commit:
git add src/State/ClientTicketStatusProcessor.php
git commit -m "feat(state) : create ClientTicketStatusProcessor with transition validation"
Task 15: Create ClientTicketProvider (GetCollection + Get)
- Create
src/State/ClientTicketProvider.phpwith the following complete content:
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\ClientTicket;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<ClientTicket>
*/
final readonly class ClientTicketProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
) {}
/**
* @return ClientTicket|list<ClientTicket>|null
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ClientTicket|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(ClientTicket::class);
// Single item
if (isset($uriVariables['id'])) {
$ticket = $repo->find($uriVariables['id']);
if (null === $ticket) {
return null;
}
if (!$this->security->isGranted('ROLE_ADMIN') && $ticket->getSubmittedBy() !== $user) {
return null;
}
return $ticket;
}
// Collection with manual filtering
$qb = $repo->createQueryBuilder('ct')
->orderBy('ct.createdAt', 'DESC');
// ROLE_CLIENT: only own tickets
if (!$this->security->isGranted('ROLE_ADMIN')) {
$qb->andWhere('ct.submittedBy = :user')->setParameter('user', $user);
}
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$qb->andWhere('ct.project = :project')->setParameter('project', (int) basename($filters['project']));
}
if (isset($filters['status'])) {
$qb->andWhere('ct.status = :status')->setParameter('status', $filters['status']);
}
if (isset($filters['submittedBy']) && $this->security->isGranted('ROLE_ADMIN')) {
$qb->andWhere('ct.submittedBy = :submittedBy')->setParameter('submittedBy', (int) basename($filters['submittedBy']));
}
return $qb->getQuery()->getResult();
}
}
- Commit:
git add src/State/ClientTicketProvider.php
git commit -m "feat(state) : create ClientTicketProvider with role-based filtering"
Task 16: Generalize TaskDocumentProcessor for client tickets
- Modify
src/State/TaskDocumentProcessor.php— Add the ClientTicket import and AccessDeniedHttpException import after the existing imports (after line 10):
use App\Entity\ClientTicket;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
- Add
Securitydependency injection — Addprivate Security $securityto the constructor parameters and add the import:
use Symfony\Bundle\SecurityBundle\Security;
- Replace the task IRI validation and lookup logic (lines 53-65). Replace:
$taskIri = $request->request->get('task');
if (null === $taskIri || '' === $taskIri) {
throw new BadRequestHttpException('Task IRI is required.');
}
// Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
$taskId = (int) basename((string) $taskIri);
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
With:
$taskIri = $request->request->get('task');
$clientTicketIri = $request->request->get('clientTicket');
if ((null === $taskIri || '' === $taskIri) && (null === $clientTicketIri || '' === $clientTicketIri)) {
throw new BadRequestHttpException('Either task or clientTicket IRI is required.');
}
$task = null;
$clientTicket = null;
if (null !== $taskIri && '' !== $taskIri) {
$taskId = (int) basename((string) $taskIri);
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
}
if (null !== $clientTicketIri && '' !== $clientTicketIri) {
$clientTicketId = (int) basename((string) $clientTicketIri);
$clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find($clientTicketId);
if (null === $clientTicket) {
throw new BadRequestHttpException('Client ticket not found.');
}
// ROLE_CLIENT can only upload to their own tickets
if (null !== $clientTicket && !$this->security->isGranted('ROLE_ADMIN')) {
$currentUser = $this->security->getUser();
if ($clientTicket->getSubmittedBy() !== $currentUser) {
throw new AccessDeniedHttpException('You can only upload documents to your own tickets.');
}
}
}
- Update the document creation — Replace line 82:
$document->setTask($task);
With:
$document->setTask($task);
$document->setClientTicket($clientTicket);
- Commit:
git add src/State/TaskDocumentProcessor.php
git commit -m "feat(state) : generalize TaskDocumentProcessor to accept task or clientTicket"
Chunk 5: Admin User Management for Client Users
Task 17: Update UserData and UserWrite DTOs
- Modify
frontend/services/dto/user-data.ts— Replace the entire file content with:
import type { Client } from './client'
import type { Project } from './project'
export type UserData = {
id: number
'@id'?: string
username: string
roles: string[]
client?: Client | null
allowedProjects?: Project[]
}
export type UserWrite = {
username: string
password?: string
roles: string[]
client?: string | null
allowedProjects?: string[]
}
- Commit:
git add frontend/services/dto/user-data.ts
git commit -m "feat(frontend) : add client and allowedProjects to User DTOs"
Task 18: Update UserDrawer to support client user creation
- Modify
frontend/components/user/UserDrawer.vue— Replace the entire file content with:
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.username"
label="Nom d'utilisateur"
input-class="w-full"
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true"
/>
<MalioInputText
v-model="form.password"
label="Mot de passe"
input-class="w-full"
type="password"
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
@blur="touched.password = true"
/>
<div class="mt-4">
<label class="text-sm font-semibold text-neutral-700">Rôles</label>
<div class="mt-2 flex flex-col gap-2">
<label
v-for="role in availableRoles"
:key="role"
class="flex items-center gap-2 text-sm text-neutral-700"
>
<input
v-model="form.roles"
type="checkbox"
:value="role"
class="rounded border-neutral-300"
@change="onRoleChange"
/>
{{ role }}
</label>
</div>
</div>
<template v-if="isClientRole">
<div class="mt-4">
<MalioSelect
v-model="form.clientId"
label="Client"
:options="clientOptions"
placeholder="Sélectionner un client"
/>
</div>
<div v-if="form.clientId" class="mt-4">
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
<div class="mt-2 flex flex-col gap-2">
<label
v-for="project in clientProjects"
:key="project.id"
class="flex items-center gap-2 text-sm text-neutral-700"
>
<input
v-model="form.allowedProjectIds"
type="checkbox"
:value="project.id"
class="rounded border-neutral-300"
/>
{{ project.name }}
</label>
<p v-if="clientProjects.length === 0" class="text-sm text-neutral-400">
Aucun projet lié à ce client.
</p>
</div>
</div>
</template>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { UserData, UserWrite } from '~/services/dto/user-data'
import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project'
import { useUserService } from '~/services/users'
import { useClientService } from '~/services/clients'
import { useProjectService } from '~/services/projects'
const props = defineProps<{
modelValue: boolean
item: UserData | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
username: '',
password: '',
roles: [] as string[],
clientId: null as number | null,
allowedProjectIds: [] as number[],
})
const touched = reactive({
username: false,
password: false,
})
const clients = ref<Client[]>([])
const projects = ref<Project[]>([])
const clientOptions = computed(() =>
clients.value.map(c => ({ label: c.name, value: c.id })),
)
const isClientRole = computed(() => form.roles.includes('ROLE_CLIENT'))
const clientProjects = computed(() => {
if (!form.clientId) return []
return projects.value.filter(p => p.client?.id === form.clientId)
})
function onRoleChange() {
if (!form.roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
}
watch(() => form.clientId, () => {
form.allowedProjectIds = []
})
watch(() => props.modelValue, async (open) => {
if (open) {
if (props.item) {
form.username = props.item.username ?? ''
form.password = ''
form.roles = [...props.item.roles]
form.clientId = props.item.client?.id ?? null
form.allowedProjectIds = (props.item.allowedProjects ?? []).map(p => p.id)
} else {
form.username = ''
form.password = ''
form.roles = ['ROLE_USER']
form.clientId = null
form.allowedProjectIds = []
}
touched.username = false
touched.password = false
// Load clients and projects for client user management
const { getAll: getAllClients } = useClientService()
const { getAll: getAllProjects } = useProjectService()
const [c, p] = await Promise.all([getAllClients(), getAllProjects()])
clients.value = c
projects.value = p
}
})
const { create, update } = useUserService()
async function handleSubmit() {
touched.username = true
touched.password = true
if (!form.username.trim()) return
if (!isEditing.value && !form.password) return
isSubmitting.value = true
try {
const payload: UserWrite = {
username: form.username.trim(),
roles: form.roles,
}
if (form.password) {
payload.password = form.password
}
if (isClientRole.value) {
payload.client = form.clientId ? `/api/clients/${form.clientId}` : null
payload.allowedProjects = form.allowedProjectIds.map(id => `/api/projects/${id}`)
} else {
payload.client = null
payload.allowedProjects = []
}
if (isEditing.value && props.item) {
await update(props.item.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
- Commit:
git add frontend/components/user/UserDrawer.vue
git commit -m "feat(frontend) : update UserDrawer to support client user creation with client and projects"
Chunk 6: Frontend Services & DTOs
Task 19: Create ClientTicket DTO
- Create
frontend/services/dto/client-ticket.tswith the following content:
import type { TaskDocument } from './task-document'
import type { UserData } from './user-data'
export type ClientTicketType = 'bug' | 'improvement' | 'other'
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
export type ClientTicket = {
'@id'?: string
id: number
number: number
type: ClientTicketType
title: string
description: string
url: string | null
status: ClientTicketStatus
statusComment: string | null
project: string
submittedBy: UserData | null
createdAt: string
updatedAt: string
documents: TaskDocument[]
}
export type ClientTicketWrite = {
type: ClientTicketType
title: string
description: string
url?: string | null
project: string
}
- Commit:
git add frontend/services/dto/client-ticket.ts
git commit -m "feat(frontend) : create ClientTicket TypeScript DTOs"
Task 20: Create client-tickets service
- Create
frontend/services/client-tickets.tswith the following content:
import type { ClientTicket, ClientTicketWrite } from './dto/client-ticket'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useClientTicketService() {
const api = useApi()
async function getAll(params?: Record<string, string | number>): Promise<ClientTicket[]> {
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', params)
return extractHydraMembers(data)
}
async function getById(id: number): Promise<ClientTicket> {
return await api.get<ClientTicket>(`/client_tickets/${id}`)
}
async function create(data: ClientTicketWrite): Promise<ClientTicket> {
return await api.post<ClientTicket>('/client_tickets', data as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.created',
})
}
async function updateStatus(id: number, status: string, statusComment?: string): Promise<ClientTicket> {
return await api.patch<ClientTicket>(`/client_tickets/${id}`, {
status,
...(statusComment ? { statusComment } : {}),
}, {
toastSuccessKey: 'clientTicket.statusUpdated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/client_tickets/${id}`, {}, {
toastSuccessKey: 'clientTicket.deleted',
})
}
return { getAll, getById, create, updateStatus, remove }
}
- Commit:
git add frontend/services/client-tickets.ts
git commit -m "feat(frontend) : create client-tickets API service"
Task 21: Update Task DTO with clientTicket field
- Modify
frontend/services/dto/task.ts— Add the import at the top of the file (after line 8):
import type { ClientTicket } from './client-ticket'
- Add the
clientTicketfield to theTasktype. After thedocuments: TaskDocument[]line (line 23), add:
clientTicket?: { id: number; number: number; type: string; status: string; title: string } | null
- Add the
clientTicketfield to theTaskWritetype. After thetags: string[]line (line 36), add:
clientTicket?: string | null
- Commit:
git add frontend/services/dto/task.ts
git commit -m "feat(frontend) : add clientTicket field to Task DTO"
Task 22: Update TaskDocument DTO with clientTicket field
- Modify
frontend/services/dto/task-document.ts— Replace the entire content with:
import type { UserData } from './user-data'
export type TaskDocument = {
'@id'?: string
id: number
task: string | null
clientTicket?: string | null
originalName: string
fileName: string
mimeType: string
size: number
createdAt: string
uploadedBy: UserData | null
}
- Commit:
git add frontend/services/dto/task-document.ts
git commit -m "feat(frontend) : add clientTicket field to TaskDocument DTO"
Task 23: Add i18n translations for client portal
- Modify
frontend/i18n/locales/fr.json— Add the following keys at the end of the JSON object, before the closing}. After the"bookstack"block (after line 237), add:
,
"portal": {
"title": "Portail client",
"projects": "Mes projets",
"openTickets": "tickets ouverts",
"noProjects": "Aucun projet disponible.",
"newTicket": "Nouveau ticket",
"ticketDetail": "Détail du ticket"
},
"clientTicket": {
"title": "Tickets client",
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
"improvement": "Amélioration",
"other": "Autre"
},
"status": {
"new": "Nouveau",
"in_progress": "En cours",
"done": "Terminé",
"rejected": "Rejeté"
},
"fields": {
"title": "Titre",
"description": "Description",
"url": "URL (page concernée)",
"urlPlaceholder": "https://example.com/page-concernee",
"type": "Type",
"project": "Projet"
},
"confirmDelete": "Supprimer ce ticket ?",
"rejectComment": "Commentaire de rejet",
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
"linkedTooltip": "Lié au ticket client CT-{number}"
}
- Commit:
git add frontend/i18n/locales/fr.json
git commit -m "feat(i18n) : add French translations for client portal and client tickets"