Files
Lesstime/docs/superpowers/plans/2026-03-15-client-portal-phase1.md
2026-03-15 19:18:25 +01:00

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 — Change getRoles() at line 96 so that ROLE_USER is NOT added when the user has ROLE_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 — Add security to 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 — Add security to 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 the password_hashers block (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 existing use statements (after line 20):
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
  • Add the client and allowedProjects properties after the $password property (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:read to User's id and username Groups so that the submittedBy relation on ClientTicket embeds user data instead of a plain IRI. Find the existing $id and $username properties 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.php with 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 the clientTicket property after the $documents property (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 — Make task nullable. Replace line 47:
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]

With:

    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
  • Add clientTicket property after the $task property (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 clientTicket ApiFilter — 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:read to TaskDocument's serialization Groups so that the documents relation 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_ticket with columns: id, number, type, title, description, url, status, status_comment, project_id, submitted_by_id, created_at, updated_at
    • CREATE TABLE user_allowed_projects with columns: user_id, project_id
    • ALTER TABLE "user" ADD client_id (nullable FK)
    • ALTER TABLE task ADD client_ticket_id (nullable FK)
    • ALTER TABLE task_document making task_id nullable and adding client_ticket_id
    • Unique constraint on (project_id, number) for client_ticket
  • Add CHECK constraint to the migration — In the up() method, add this line after the task_document alterations:

        $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.php with 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.php with 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.php with 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.php with 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 Security dependency injection — Add private Security $security to 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.ts with 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.ts with 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 clientTicket field to the Task type. After the documents: TaskDocument[] line (line 23), add:
    clientTicket?: { id: number; number: number; type: string; status: string; title: string } | null
  • Add the clientTicket field to the TaskWrite type. After the tags: 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"