# 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 */ public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_values(array_unique($roles)); } ``` With: ```php /** @return list */ 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 */ #[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 */ 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 ['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 */ #[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 */ 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 */ 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 */ 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 */ final readonly class ClientTicketStatusProcessor implements ProcessorInterface { /** @var array> */ 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 */ final readonly class ClientTicketProvider implements ProviderInterface { public function __construct( private Security $security, private EntityManagerInterface $entityManager, ) {} /** * @return ClientTicket|list|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 ``` - [ ] **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): Promise { const data = await api.get>('/client_tickets', params) return extractHydraMembers(data) } async function getById(id: number): Promise { return await api.get(`/client_tickets/${id}`) } async function create(data: ClientTicketWrite): Promise { return await api.post('/client_tickets', data as Record, { toastSuccessKey: 'clientTicket.created', }) } async function updateStatus(id: number, status: string, statusComment?: string): Promise { return await api.patch(`/client_tickets/${id}`, { status, ...(statusComment ? { statusComment } : {}), }, { toastSuccessKey: 'clientTicket.statusUpdated', }) } async function remove(id: number): Promise { 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" ```