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

1586 lines
47 KiB
Markdown

# 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):
```php
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
```
With:
```php
/** @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:**
```bash
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:
```php
new GetCollection(paginationEnabled: false),
new Get(),
```
With:
```php
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:
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Commit:**
```bash
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):
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Modify `src/Entity/TaskStatus.php`** — Replace (lines 19-20):
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Modify `src/Entity/TaskEffort.php`** — Replace (lines 19-20):
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Modify `src/Entity/TaskPriority.php`** — Replace (lines 19-20):
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Commit:**
```bash
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):
```php
new GetCollection(),
new Get(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Modify `src/Entity/TaskGroup.php`** — Replace (lines 21-22):
```php
new GetCollection(),
new Get(),
```
With:
```php
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):
```php
new GetCollection(),
```
With:
```php
new GetCollection(security: "is_granted('ROLE_USER')"),
```
And:
```php
new Get(),
```
With:
```php
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Modify `src/Entity/TaskDocument.php`** — Replace (lines 22-23):
```php
new GetCollection(paginationEnabled: false),
new Get(),
```
With:
```php
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
```
- [ ] **Commit:**
```bash
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:
```yaml
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:
```yaml
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:**
```bash
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):
```php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
```
- [ ] **Add the `client` and `allowedProjects` properties** after the `$password` property (after line 63):
```php
#[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:
```php
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
```
With:
```php
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->allowedProjects = new ArrayCollection();
}
```
- [ ] **Add getters and setters** before the `eraseCredentials()` method (before line 136):
```php
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:
```php
#[Groups(['user:list', 'me:read'])]
private ?int $id = null;
```
With:
```php
#[Groups(['user:list', 'me:read', 'client_ticket:read'])]
private ?int $id = null;
```
Replace the existing `$username` Groups:
```php
#[Groups(['user:list', 'me:read'])]
private ?string $username = null;
```
With:
```php
#[Groups(['user:list', 'me:read', 'client_ticket:read'])]
private ?string $username = null;
```
- [ ] **Commit:**
```bash
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
<?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:**
```bash
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):
```php
#[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 `}`):
```php
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
```
- [ ] **Commit:**
```bash
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:
```php
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
```
With:
```php
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
```
- [ ] **Add `clientTicket` property** after the `$task` property (after line 49):
```php
#[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):
```php
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
```
With:
```php
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
```
- [ ] **Update the Post operation security** to also allow ROLE_CLIENT. Replace (line 24-28):
```php
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
```
With:
```php
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:
```php
#[Groups(['task_document:read'])]
private ?int $id = null;
```
With:
```php
#[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):
```php
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
```
- [ ] **Commit:**
```bash
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:**
```bash
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:
```php
$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:
```php
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT IF EXISTS chk_document_owner');
```
- [ ] **Commit:**
```bash
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:**
```bash
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
<?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:**
```bash
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
<?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:**
```bash
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
<?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:**
```bash
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
<?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:**
```bash
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):
```php
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:
```php
use Symfony\Bundle\SecurityBundle\Security;
```
- [ ] **Replace the task IRI validation and lookup logic** (lines 53-65). Replace:
```php
$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:
```php
$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:
```php
$document->setTask($task);
```
With:
```php
$document->setTask($task);
$document->setClientTicket($clientTicket);
```
- [ ] **Commit:**
```bash
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:
```typescript
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:**
```bash
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:
```vue
<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:**
```bash
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:
```typescript
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:**
```bash
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:
```typescript
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:**
```bash
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):
```typescript
import type { ClientTicket } from './client-ticket'
```
- [ ] **Add the `clientTicket` field** to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add:
```typescript
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:
```typescript
clientTicket?: string | null
```
- [ ] **Commit:**
```bash
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:
```typescript
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:**
```bash
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:
```json
,
"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:**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(i18n) : add French translations for client portal and client tickets"
```