From f4eec2e6e94fe8d61da8c5f9122b3378eb391747 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:18:25 +0100 Subject: [PATCH] docs : add client portal implementation plans (phases 1-3) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-client-portal-phase1.md | 1585 +++++++++++++ .../plans/2026-03-15-client-portal-phase2.md | 1960 +++++++++++++++++ .../plans/2026-03-15-client-portal-phase3.md | 970 ++++++++ 3 files changed, 4515 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase1.md create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase2.md create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase3.md diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase1.md b/docs/superpowers/plans/2026-03-15-client-portal-phase1.md new file mode 100644 index 0000000..8b159d9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase1.md @@ -0,0 +1,1585 @@ +# 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" +``` diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase2.md b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md new file mode 100644 index 0000000..badd515 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md @@ -0,0 +1,1960 @@ +# Client Portal Phase 2 — Portal & UI + +> **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:** Build the client-facing portal pages (project list, ticket list, ticket creation with document upload), add client ticket indicators on internal kanban/my-tasks views, and create the admin "Tickets client" tab for managing all tickets. + +**Architecture:** Portal pages live under `/portal/` and use the existing default layout with a simplified sidebar for ROLE_CLIENT users. Auth middleware is extended to redirect ROLE_CLIENT to `/portal` and block internal pages. Client ticket data on internal task views flows through the `task:read` serialization group (no extra API call). Admin tab follows the existing tab pattern in `admin.vue`. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +**Depends on:** Phase 1 (`docs/superpowers/plans/2026-03-15-client-portal-phase1.md`) + +--- + +## Chunk 1: Auth Middleware & Portal Layout + +### Task 1: Update auth middleware for ROLE_CLIENT routing + +- [ ] **Modify `frontend/middleware/auth.global.ts`** — Add ROLE_CLIENT redirect logic. After the existing login redirect (line 14), add portal routing. Replace the full file with: + +```typescript +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + return navigateTo(isClient ? '/portal' : '/') + } + + // ROLE_CLIENT: redirect to /portal, block internal pages + if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { + const isPortalRoute = to.path.startsWith('/portal') + const isLoginRoute = to.path === '/login' + if (!isPortalRoute && !isLoginRoute) { + return navigateTo('/portal') + } + } +}) +``` + +- [ ] **Commit:** +```bash +git add frontend/middleware/auth.global.ts +git commit -m "feat(auth) : redirect ROLE_CLIENT to /portal and block internal pages" +``` + +### Task 2: Create portal layout + +- [ ] **Create `frontend/layouts/portal.vue`** — Simplified layout for client users with minimal sidebar (logo, portal link, logout): + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/layouts/portal.vue +git commit -m "feat(portal) : add portal layout with simplified sidebar for client users" +``` + +### Task 3: Add i18n keys for portal and client tickets + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add portal and clientTicket sections. After the `"bookstack"` block (before the closing `}`), add: + +```json + "portal": { + "title": "Portail client", + "projects": "Mes projets", + "openTickets": "tickets ouverts", + "noProjects": "Aucun projet disponible.", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket", + "backToProject": "Retour au projet", + "submitTicket": "Soumettre le ticket", + "ticketCreated": "Ticket soumis avec succès." + }, + "clientTicket": { + "type": { + "bug": "Bug", + "improvement": "Amélioration", + "other": "Autre" + }, + "status": { + "new": "Nouveau", + "in_progress": "En cours", + "done": "Terminé", + "rejected": "Rejeté" + }, + "title": "Titre", + "description": "Description", + "url": "URL (page concernée)", + "statusComment": "Commentaire de statut", + "created": "Ticket créé", + "statusChanged": "Statut mis à jour", + "confirmDelete": "Supprimer ce ticket ?", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + "linkedTooltip": "Lié au ticket client {number}", + "rejectionRequired": "Un commentaire est requis pour rejeter un ticket", + "noTickets": "Aucun ticket.", + "allStatuses": "Tous les statuts", + "allProjects": "Tous les projets", + "submittedBy": "Soumis par", + "createdAt": "Créé le", + "deleted": "Ticket supprimé avec succès.", + "statusUpdated": "Statut mis à jour avec succès.", + "adminTab": "Tickets client", + "selectType": "Type de ticket", + "changeStatus": "Changer le statut" + } +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add portal and client ticket translation keys" +``` + +--- + +## Chunk 2: DTOs & Services + +### Task 4: Create ClientTicket DTO + +- [ ] **Create `frontend/services/dto/client-ticket.ts`** — TypeScript types for client tickets: + +```typescript +import type { TaskDocument } from './task-document' + +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: string | null + createdAt: string + updatedAt: string + documents?: TaskDocument[] +} + +export type ClientTicketWrite = { + type: ClientTicketType + title: string + description: string + url?: string | null + project: string +} + +export type ClientTicketStatusUpdate = { + status: ClientTicketStatus + statusComment?: string | null +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/client-ticket.ts +git commit -m "feat(dto) : add ClientTicket TypeScript types" +``` + +### Task 5: Create client-tickets service + +- [ ] **Create `frontend/services/client-tickets.ts`** — API service for client tickets following the existing service pattern (`useTaskService`): + +```typescript +import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } 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?: { project?: number; status?: string; submittedBy?: number }): Promise { + const query: Record = {} + if (params?.project) query.project = `/api/projects/${params.project}` + if (params?.status) query.status = params.status + if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}` + const data = await api.get>('/client_tickets', query) + return extractHydraMembers(data) + } + + async function getById(id: number): Promise { + return api.get(`/client_tickets/${id}`) + } + + async function create(payload: ClientTicketWrite): Promise { + return api.post('/client_tickets', payload as Record, { + toastSuccessKey: 'portal.ticketCreated', + }) + } + + async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise { + return api.patch(`/client_tickets/${id}`, payload as Record, { + 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(service) : add client-tickets API service" +``` + +### Task 6: Extend Task DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task.ts`** — Add `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 +``` + +The full `Task` type should now include `clientTicket` after `documents`: + +```typescript +import type { TaskStatus } from './task-status' +import type { TaskEffort } from './task-effort' +import type { TaskPriority } from './task-priority' +import type { TaskTag } from './task-tag' +import type { TaskGroup } from './task-group' +import type { UserData } from './user-data' +import type { Project } from './project' +import type { TaskDocument } from './task-document' + +export type Task = { + id: number + '@id'?: string + number: number + title: string + description: string | null + status: TaskStatus | null + effort: TaskEffort | null + priority: TaskPriority | null + assignee: UserData | null + group: TaskGroup | null + project: Project | null + tags: TaskTag[] + documents: TaskDocument[] + archived: boolean + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null +} + +export type TaskWrite = { + title: string + description: string | null + status: string | null + effort: string | null + priority: string | null + assignee: string | null + group: string | null + project: string + tags: string[] + archived?: boolean +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task.ts +git commit -m "feat(dto) : add clientTicket field to Task type" +``` + +### Task 7: Update UserData DTO for allowedProjects + +- [ ] **Modify `frontend/services/dto/user-data.ts`** — Add `client` and `allowedProjects` fields for client users. This must happen before portal pages are built because `auth.user.allowedProjects` needs proper typing. Replace the full file with: + +```typescript +import type { Project } from './project' + +export type UserData = { + id: number + '@id'?: string + username: string + roles: string[] + client?: { id: number; name: string } | 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(dto) : add client and allowedProjects fields to UserData type" +``` + +--- + +## Chunk 3: Portal Pages + +### Task 8: Create portal project list page + +- [ ] **Create `frontend/pages/portal/index.vue`** — List of client's allowed projects with open ticket count. Uses the `portal` layout. **Note:** For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have no `allowedProjects`: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/index.vue +git commit -m "feat(portal) : add portal project list page" +``` + +### Task 9: Create portal ticket list page + +- [ ] **Create `frontend/pages/portal/projects/[id]/index.vue`** — List of tickets for a project with status badges and ticket detail modal: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/index.vue +git commit -m "feat(portal) : add ticket list page for a project" +``` + +### Task 10: Create ClientTicketDetailModal component + +- [ ] **Create `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Read-only modal showing ticket details (title, description, url, status, statusComment, documents). Follows the `TaskModal` pattern for styling: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add client ticket detail modal component" +``` + +### Task 11: Create new ticket form page + +- [ ] **Create `frontend/pages/portal/projects/[id]/new-ticket.vue`** — Ticket creation form with type select, title, description, url (if bug), and document upload: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/new-ticket.vue +git commit -m "feat(portal) : add new ticket creation form page" +``` + +--- + +## Chunk 4: Document Upload on Tickets + +### Task 12: Generalize TaskDocumentUpload with optional clientTicketId prop + +- [ ] **Modify `frontend/components/task/TaskDocumentUpload.vue`** — Add an optional `clientTicketId` prop as an alternative to `taskId`. Replace the ` +``` + +Also update the template references. Replace: +```vue + +``` + +With: +```vue + +``` + +And replace: +```vue + +``` + +With: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add document upload to ticket detail modal" +``` + +--- + +## Chunk 5: Client Ticket Icon on Internal Views + +### Task 15: Add client ticket icon to TaskCard + +- [ ] **Modify `frontend/components/task/TaskCard.vue`** — Add a small `heroicons:user-circle` icon next to the task code if `task.clientTicket` is set. In the template, after the `` showing `task.project.code` (line 11), add the icon. Replace: + +```vue + {{ task.project.code }}{{ task.number }} +``` + +With: +```vue +
+ {{ task.project.code }}{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/components/task/TaskCard.vue +git commit -m "feat(kanban) : show client ticket icon on task cards linked to a ticket" +``` + +### Task 16: Add client ticket icon to my-tasks list view + +- [ ] **Modify `frontend/pages/my-tasks.vue`** — Add the same `heroicons:user-circle` icon in the list view. In the list view task row, after the task code span (around line 418), add the icon. Replace: + +```vue + + {{ task.project.code }}-{{ task.number }} + +``` + +With: +```vue +
+ + + {{ task.project.code }}-{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/pages/my-tasks.vue +git commit -m "feat(my-tasks) : show client ticket icon on tasks linked to a ticket" +``` + +### Task 17: Show client ticket info in TaskModal + +- [ ] **Modify `frontend/components/task/TaskModal.vue`** — Show client ticket link info when editing a task that has `clientTicket` set. In the template, after the header `

` tag (line 27), add a client ticket badge. After the closing `` of the header flex container (line 29), add: + +```vue + +
+ + + {{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }} + + + {{ $t(`clientTicket.status.${task.clientTicket.status}`) }} + +
+``` + +In the ` + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/admin/AdminClientTicketTab.vue +git commit -m "feat(admin) : add client tickets tab with list, filters, status change, and delete" +``` + +### Task 19: Register the new tab in admin.vue + +- [ ] **Modify `frontend/pages/admin.vue`** — Add the "Tickets client" tab. In the `tabs` array (line 39), add a new entry after the `bookstack` tab: + +Replace: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +With: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'client-tickets', label: 'Tickets client' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +In the template, after the `AdminBookStackTab` (line 31), add: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/admin.vue +git commit -m "feat(admin) : register client tickets tab in admin page" +``` + +--- + +## Chunk 7: Final Touches + +### Task 20: Update login redirect to portal for client users + +- [ ] **Modify `frontend/pages/login.vue`** — After successful login, redirect ROLE_CLIENT users to `/portal` instead of `/`. The actual login page uses `router.push`, not `navigateTo`. + +Find this line (around line 66): +```typescript + await router.push('/') +``` + +Replace with: +```typescript + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + await router.push(isClient ? '/portal' : '/') +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/login.vue +git commit -m "feat(auth) : redirect client users to /portal after login" +``` + +### Task 21: Extract duplicated helpers to composable + +- [ ] **Create `frontend/composables/useClientTicketHelpers.ts`** — Extract the `typeBadgeClass`, `statusBadgeClass`, and `formatDate` functions that are duplicated in `ClientTicketDetailModal.vue`, `portal/projects/[id]/index.vue`, and `AdminClientTicketTab.vue`: + +```typescript +export function useClientTicketHelpers() { + function typeBadgeClass(type: string): string { + switch (type) { + case 'bug': return 'bg-red-500' + case 'improvement': return 'bg-blue-500' + default: return 'bg-neutral-500' + } + } + + function statusBadgeClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-100 text-blue-700' + case 'in_progress': return 'bg-yellow-100 text-yellow-700' + case 'done': return 'bg-green-100 text-green-700' + case 'rejected': return 'bg-red-100 text-red-700' + default: return 'bg-neutral-100 text-neutral-700' + } + } + + function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + return { typeBadgeClass, statusBadgeClass, formatDate } +} +``` + +- [ ] **Update the 3 components** to import and use the composable instead of local functions: + - `frontend/components/client-ticket/ClientTicketDetailModal.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + - `frontend/pages/portal/projects/[id]/index.vue` — Same replacement + - `frontend/components/admin/AdminClientTicketTab.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + +- [ ] **Commit:** +```bash +git add frontend/composables/useClientTicketHelpers.ts frontend/components/client-ticket/ClientTicketDetailModal.vue frontend/pages/portal/projects/\[id\]/index.vue frontend/components/admin/AdminClientTicketTab.vue +git commit -m "refactor(portal) : extract duplicated ticket helpers to useClientTicketHelpers composable" +``` + +### Task 22: Final commit — verify all files + +- [ ] **Run a final check** — Verify all new files are properly created and existing files are updated: +```bash +git status +``` + +Verify the following files exist: +- `frontend/middleware/auth.global.ts` (modified) +- `frontend/layouts/portal.vue` (new) +- `frontend/i18n/locales/fr.json` (modified) +- `frontend/services/dto/client-ticket.ts` (new) +- `frontend/services/client-tickets.ts` (new) +- `frontend/services/dto/task.ts` (modified) +- `frontend/services/dto/user-data.ts` (modified) +- `frontend/services/task-documents.ts` (modified) +- `frontend/pages/portal/index.vue` (new) +- `frontend/pages/portal/projects/[id]/index.vue` (new) +- `frontend/pages/portal/projects/[id]/new-ticket.vue` (new) +- `frontend/components/client-ticket/ClientTicketDetailModal.vue` (new) +- `frontend/components/task/TaskDocumentUpload.vue` (modified) +- `frontend/components/task/TaskCard.vue` (modified) +- `frontend/components/task/TaskModal.vue` (modified) +- `frontend/pages/my-tasks.vue` (modified) +- `frontend/pages/admin.vue` (modified) +- `frontend/components/admin/AdminClientTicketTab.vue` (new) +- `frontend/pages/login.vue` (modified) +- `frontend/composables/useClientTicketHelpers.ts` (new) diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase3.md b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md new file mode 100644 index 0000000..b61a712 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md @@ -0,0 +1,970 @@ +# Client Portal Phase 3 — Notifications + +> **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:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service). + +**Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`. + +> **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity. + +**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` + +**Depends on:** Phase 1 + Phase 2 + +--- + +## Chunk 1: Notification Entity & Migration + +### Task 1: Create the Notification entity + +- [ ] **Create `src/Entity/Notification.php`** with the following content: + +```php + ['notification:read']], + denormalizationContext: ['groups' => ['notification:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: NotificationRepository::class)] +#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] +#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] +class Notification +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['notification:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['notification:read'])] + private ?User $user = null; + + #[ORM\Column(length: 50)] + #[Groups(['notification:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['notification:read'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT)] + #[Groups(['notification:read'])] + private ?string $message = null; + + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['notification:read'])] + private ?ClientTicket $relatedTicket = null; + + #[ORM\Column] + #[Groups(['notification:read', 'notification:write'])] + private bool $isRead = false; + + #[ORM\Column] + #[Groups(['notification:read'])] + private ?DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + 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 getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getRelatedTicket(): ?ClientTicket + { + return $this->relatedTicket; + } + + public function setRelatedTicket(?ClientTicket $relatedTicket): static + { + $this->relatedTicket = $relatedTicket; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} +``` + +### Task 2: Create the NotificationRepository + +- [ ] **Create `src/Repository/NotificationRepository.php`**: + +```php + + */ +class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + public function countUnreadByUser(User $user): int + { + return (int) $this->createQueryBuilder('n') + ->select('COUNT(n.id)') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + } + + public function markAllReadByUser(User $user): int + { + return $this->createQueryBuilder('n') + ->update() + ->set('n.isRead', 'true') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->executeStatement(); + } +} +``` + +### Task 3: Generate and run the migration + +- [ ] **Run inside the PHP container** (`make shell`): + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`. + +- [ ] **Commit:** +```bash +git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/ +git commit -m "feat(notification) : add Notification entity, repository, and migration" +``` + +--- + +## Chunk 2: NotificationProvider & Custom Endpoints + +### Task 4: Create the NotificationProvider + +- [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user: + +```php + + */ +final readonly class NotificationProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private NotificationRepository $notificationRepository, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object + { + $user = $this->security->getUser(); + + return $this->notificationRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 30, + ); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/NotificationProvider.php +git commit -m "feat(notification) : add NotificationProvider filtered by current user" +``` + +### Task 5: Create the UnreadCountController + +- [ ] **Create `src/Controller/NotificationUnreadCountController.php`**: + +```php +getUser(); + + $count = $this->notificationRepository->countUnreadByUser($user); + + return new JsonResponse(['count' => $count]); + } +} +``` + +### Task 6: Create the MarkAllReadController + +- [ ] **Create `src/Controller/MarkAllReadController.php`**: + +```php +getUser(); + + $this->notificationRepository->markAllReadByUser($user); + + return new Response(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php +git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers" +``` + +--- + +## Chunk 3: NotificationService & Processor Integration + +### Task 7: Create NotificationService + +- [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications: + +```php +userRepository->findByRole('ROLE_ADMIN'); + $number = sprintf('CT-%03d', $ticket->getNumber()); + $projectName = $ticket->getProject()?->getName() ?? ''; + + foreach ($admins as $admin) { + $notification = new Notification(); + $notification->setUser($admin); + $notification->setType('ticket_created'); + $notification->setTitle('Nouveau ticket client ' . $number); + $notification->setMessage($ticket->getTitle() . ' — ' . $projectName); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + } + + $this->entityManager->flush(); + } + + /** + * Notify the ticket submitter that the status has changed. + */ + public function createForStatusChange(ClientTicket $ticket): void + { + $submittedBy = $ticket->getSubmittedBy(); + + if (null === $submittedBy) { + return; + } + + $number = sprintf('CT-%03d', $ticket->getNumber()); + $statusLabel = $ticket->getStatus(); + $message = 'Nouveau statut : ' . $statusLabel; + + if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) { + $message .= ' — ' . $ticket->getStatusComment(); + } + + $notification = new Notification(); + $notification->setUser($submittedBy); + $notification->setType('ticket_status_changed'); + $notification->setTitle('Ticket ' . $number . ' mis à jour'); + $notification->setMessage($message); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + } +} +``` + +### Task 8: Add findByRole method to UserRepository + +- [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`: + +```php + /** + * @return User[] + */ + public function findByRole(string $role): array + { + return $this->createQueryBuilder('u') + ->where('u.roles LIKE :role') + ->setParameter('role', '%"' . $role . '"%') + ->getQuery() + ->getResult(); + } +``` + +- [ ] **Commit:** +```bash +git add src/Service/NotificationService.php src/Repository/UserRepository.php +git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole" +``` + +### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST) + +- [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the POST handling block, add: +```php +$this->notificationService->createForTicketCreated($data); +``` + +### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH) + +- [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the PATCH handling block, add: +```php +$this->notificationService->createForStatusChange($data); +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php +git commit -m "feat(notification) : hook NotificationService into ticket processors" +``` + +--- + +## Chunk 4: Frontend — DTO & Service + +### Task 11: Create the Notification DTO + +- [ ] **Create `frontend/services/dto/notification.ts`**: + +```typescript +export type NotificationType = 'ticket_created' | 'ticket_status_changed' + +export type Notification = { + '@id'?: string + id: number + user: string + type: NotificationType + title: string + message: string + relatedTicket: string | null + isRead: boolean + createdAt: string +} +``` + +### Task 12: Create the notifications service + +- [ ] **Create `frontend/services/notifications.ts`**: + +```typescript +import type { Notification } from './dto/notification' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useNotificationService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/notifications') + return extractHydraMembers(data) + } + + async function markAsRead(id: number): Promise { + await api.patch(`/notifications/${id}`, { isRead: true }, { + toast: false, + }) + } + + async function markAllAsRead(): Promise { + await api.post('/notifications/mark-all-read', {}, { + toast: false, + }) + } + + async function getUnreadCount(): Promise { + const data = await api.get<{ count: number }>('/notifications/unread-count', {}, { + toast: false, + }) + return data.count + } + + return { getAll, markAsRead, markAllAsRead, getUnreadCount } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/notification.ts frontend/services/notifications.ts +git commit -m "feat(frontend) : add notification DTO and service" +``` + +--- + +## Chunk 5: Frontend — Composable & Component + +### Task 13: Create the useNotifications composable + +- [ ] **Create `frontend/composables/useNotifications.ts`**: + +```typescript +import type { Notification } from '~/services/dto/notification' +import { useNotificationService } from '~/services/notifications' + +const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes + +export function useNotifications() { + const unreadCount = useState('notification-unread-count', () => 0) + const notifications = useState('notification-list', () => []) + const isLoading = useState('notification-loading', () => false) + + const service = useNotificationService() + let pollTimer: ReturnType | null = null + + async function fetchUnreadCount(): Promise { + try { + unreadCount.value = await service.getUnreadCount() + } catch { + // Silently ignore polling errors + } + } + + async function fetchNotifications(): Promise { + isLoading.value = true + try { + notifications.value = await service.getAll() + } finally { + isLoading.value = false + } + } + + async function markAsRead(id: number): Promise { + await service.markAsRead(id) + const notif = notifications.value.find(n => n.id === id) + if (notif && !notif.isRead) { + notif.isRead = true + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } + + async function markAllAsRead(): Promise { + await service.markAllAsRead() + notifications.value.forEach(n => n.isRead = true) + unreadCount.value = 0 + } + + function startPolling(): void { + fetchUnreadCount() + pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL) + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + return { + unreadCount, + notifications, + isLoading, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + startPolling, + stopPolling, + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/composables/useNotifications.ts +git commit -m "feat(frontend) : add useNotifications composable with polling" +``` + +### Task 14: Create the NotificationBell component + +- [ ] **Create `frontend/components/notification/NotificationBell.vue`**: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/notification/NotificationBell.vue +git commit -m "feat(frontend) : add NotificationBell component with dropdown" +``` + +--- + +## Chunk 6: Layout Integration & i18n + +### Task 15: Integrate NotificationBell in AppTopNav + +- [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `
` block (line 10): + +Replace: +```vue +
+
+``` + +With: +```vue +
+ +
+``` + +No imports needed — Nuxt auto-imports components from `frontend/components/`. + +- [ ] **Commit:** +```bash +git add frontend/components/ui/AppTopNav.vue +git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar" +``` + +### Task 16: Add i18n translations + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys): + +```json +"notification": { + "title": "Notifications", + "markAllRead": "Tout marquer comme lu", + "empty": "Aucune notification", + "ticketCreated": "Nouveau ticket client {number}", + "ticketStatusChanged": "Ticket {number} mis à jour", + "timeAgo": { + "now": "À l'instant", + "minutes": "Il y a {n} min", + "hours": "Il y a {n}h", + "days": "Il y a {n}j" + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add notification translations in French" +``` + +--- + +## Chunk 7: Verification & Cleanup + +### Task 17: Test backend endpoints manually + +- [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`): + +1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}` +2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination) +3. `GET /api/notifications/unread-count` — should return `{"count": 0}` +4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin +5. `GET /api/notifications` — should now list the `ticket_created` notification +6. `GET /api/notifications/unread-count` — should return `{"count": 1}` +7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read +8. `POST /api/notifications/mark-all-read` — should return 204 + +### Task 18: Test frontend notification bell + +- [ ] **Start dev server** (`make dev-nuxt`) and verify: + +1. The bell icon appears in the top navigation bar, to the left of the user avatar +2. Badge shows unread count (or is hidden when 0) +3. Clicking the bell opens a dropdown with notification list +4. Clicking a notification marks it as read and navigates appropriately +5. "Tout marquer comme lu" button works +6. Polling updates the badge every 2 minutes + +- [ ] **Final commit (if any fixes needed):** +```bash +git add -A +git commit -m "fix(notification) : polish notification bell and fix edge cases" +```