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