docs : add client portal design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:54:49 +01:00
parent f978df6a4b
commit f5e41bc377

View File

@@ -0,0 +1,523 @@
# Portail Client — Design Spec
## Résumé
Ajout d'un portail client dans Lesstime permettant aux utilisateurs-clients de soumettre des tickets (bug, amélioration, autre) sur leurs projets, suivre l'évolution de leur traitement, et joindre des documents. Les utilisateurs internes (ROLE_ADMIN, ROLE_USER) gèrent les tickets côté admin et peuvent les lier manuellement à des tasks existantes. Un système de notifications in-app informe les parties prenantes des événements clés.
## Décisions d'architecture
- **ClientTicket est une entité séparée de Task** — cycle de vie indépendant, meilleure séparation de sécurité, maintenance simplifiée
- **Même application, vue adaptée par rôle** — pas de portail séparé. ROLE_CLIENT voit les pages `/portal`, ROLE_ADMIN/ROLE_USER voit l'app interne
- **Pas de commentaires/échanges** — communication unidirectionnelle : le client soumet, voit les changements de statut, c'est tout
- **Notifications in-app uniquement** — pas d'email pour le moment
- **Lien ticket-task manuel** — le manager crée des tasks et les lie explicitement à un ticket client
- **TaskDocument conservée** — l'entité `TaskDocument` n'est pas renommée, elle est généralisée avec un champ `clientTicket` nullable
- **Français uniquement** — l'interface est en français pour le moment, l'anglais pourra être ajouté plus tard
## Prérequis : sécurisation des endpoints existants
Avant l'introduction du rôle `ROLE_CLIENT`, il faut sécuriser l'application existante.
### Modification de `User::getRoles()`
Actuellement, `User::getRoles()` ajoute inconditionnellement `ROLE_USER` à tous les utilisateurs. Un utilisateur `ROLE_CLIENT` hériterait donc de `ROLE_USER` et pourrait accéder à toutes les données internes.
**Correction** : `getRoles()` doit ajouter `ROLE_USER` uniquement si l'utilisateur n'a PAS le rôle `ROLE_CLIENT` :
```php
public function getRoles(): array
{
$roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_unique($roles);
}
```
### Ajout de `security` sur les endpoints existants
Les endpoints existants suivants n'ont pas d'annotation `security` explicite et doivent recevoir `security: "is_granted('ROLE_USER')"` sur leurs opérations `GetCollection` et `Get` :
| Entité | Opérations à sécuriser |
|--------|----------------------|
| `Task` | GetCollection, Get |
| `Project` | GetCollection, Get |
| `Client` | GetCollection, Get |
| `TaskStatus` | GetCollection, Get |
| `TaskEffort` | GetCollection, Get |
| `TaskPriority` | GetCollection, Get |
| `TaskTag` | GetCollection, Get |
| `TaskGroup` | GetCollection, Get |
| `TimeEntry` | GetCollection, Get |
Cela garantit qu'un utilisateur `ROLE_CLIENT` ne peut pas accéder aux ressources internes via l'API.
## Modèle de données
### Entité `ClientTicket`
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `number` | int | Auto-généré, unique par projet (voir stratégie ci-dessous) |
| `type` | string (enum) | `bug`, `improvement`, `other` |
| `title` | string | Requis |
| `description` | text | Requis |
| `url` | string (nullable) | Affiché uniquement si `type = bug` |
| `status` | string (enum) | `new`, `in_progress`, `done`, `rejected` |
| `statusComment` | text (nullable) | Commentaire du manager lors d'un changement de statut |
| `project` | ManyToOne → Project | Requis |
| `submittedBy` | ManyToOne → User (nullable) | L'utilisateur-client ayant soumis le ticket. **ON DELETE SET NULL** — ne pas détruire l'historique lors de la suppression d'un utilisateur |
| `createdAt` | DateTimeImmutable | Auto |
| `updatedAt` | DateTimeImmutable | Auto |
#### Stratégie de numérotation
Numéro incrémental par projet : `SELECT MAX(number) + 1 FROM client_ticket WHERE project_id = :project`. Contrainte unique sur `(project_id, number)` avec retry en cas de conflit (concurrent insert). Le numéro affiché sera formaté `CT-001`, `CT-002`, etc. en frontend.
### Statuts des tickets (enum fixe, non configurable)
| Statut | Description |
|--------|-------------|
| `new` | Ticket venant d'être soumis |
| `in_progress` | Pris en charge par un manager |
| `done` | Résolu, client notifié |
| `rejected` | Non retenu — `statusComment` obligatoire |
#### Transitions de statut autorisées
Toutes les transitions sont autorisées, **sauf** :
- `done``new` (interdit)
- `rejected``new` (interdit)
Un ticket `done` peut repasser en `in_progress` si besoin. Un ticket `rejected` peut passer en `in_progress`. Le Processor valide les transitions et rejette les transitions interdites.
### Entité `Notification`
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `user` | ManyToOne → User | Destinataire |
| `type` | string | `ticket_created`, `ticket_status_changed` |
| `title` | string | Titre court |
| `message` | text | Contenu |
| `relatedTicket` | ManyToOne → ClientTicket (nullable) | Lien vers le ticket concerné |
| `isRead` | bool | `false` par défaut |
| `createdAt` | DateTimeImmutable | Auto |
### Modifications sur `User`
| Champ | Type | Description |
|-------|------|-------------|
| `client` | ManyToOne → Client (nullable) | `null` = utilisateur interne, set = utilisateur-client |
| `allowedProjects` | ManyToMany → Project | Projets auxquels l'utilisateur-client a accès |
Nouveau rôle : `ROLE_CLIENT`
#### Groupes de sérialisation
| Champ | Groupes |
|-------|---------|
| `client` | `me:read`, `user:read`, `user:write` |
| `allowedProjects` | `me:read`, `user:read`, `user:write` |
Règles :
- Plusieurs utilisateurs par client (1+)
- Les utilisateurs-clients sont assignés à des projets spécifiques (pas tous les projets du client)
- L'admin crée les comptes utilisateurs-clients (pas d'auto-inscription)
### Modifications sur `Task`
| Champ | Type | Description |
|-------|------|-------------|
| `clientTicket` | ManyToOne → ClientTicket (nullable) | Lien vers un ticket client |
Le champ `clientTicket` est exposé dans le groupe `task:read` avec les informations de base du ticket (number, type, status, title). Cela permet aux utilisateurs ROLE_USER d'afficher l'icône et le tooltip dans le kanban sans avoir accès à la collection `/api/client_tickets`.
### Généralisation de `TaskDocument`
L'entité `TaskDocument` existante est **conservée** (pas de renommage) et généralisée avec un champ supplémentaire :
| Champ | Modification |
|-------|-------------|
| `task` | Devient nullable |
| `clientTicket` | ManyToOne → ClientTicket (nullable) — ajouté |
**Contrainte** : au moins un des deux champs `task` / `clientTicket` doit être renseigné (CHECK constraint en base).
**Processor** : généralisé pour accepter `task` OU `clientTicket` dans le FormData.
**Sécurité** :
- ROLE_ADMIN : accès complet à tous les documents
- ROLE_USER : accès aux documents liés à une task (`task IS NOT NULL`)
- ROLE_CLIENT : accès aux documents liés à un ticket dont l'utilisateur est le `submittedBy`
## API Endpoints
Préfixe `/api`.
### ClientTicket
| Méthode | Route | Accès | Notes |
|---------|-------|-------|-------|
| `GET` | `/api/client_tickets` | ROLE_CLIENT : ses propres tickets ; ROLE_ADMIN : tous | Filtres : `project`, `status`, `submittedBy` |
| `GET` | `/api/client_tickets/{id}` | Owner ou ROLE_ADMIN | |
| `POST` | `/api/client_tickets` | ROLE_CLIENT | `submittedBy` auto-set depuis le token JWT. Le Processor valide que `user.client` n'est pas null (empêche un admin de créer un ticket même via la hiérarchie de rôles) |
| `PATCH` | `/api/client_tickets/{id}` | ROLE_ADMIN uniquement | Changement de statut + `statusComment` |
| `DELETE` | `/api/client_tickets/{id}` | ROLE_ADMIN | Cascade sur les documents liés |
**Note** : ROLE_USER n'a PAS accès à la collection `/api/client_tickets`. L'accès en lecture aux informations d'un ticket se fait via le champ `task.clientTicket` exposé dans le groupe `task:read`.
### Notification
| Méthode | Route | Accès | Notes |
|---------|-------|-------|-------|
| `GET` | `/api/notifications` | Authentifié | Auto-filtré par l'utilisateur courant. Paginé : 30 par page |
| `PATCH` | `/api/notifications/{id}` | Owner | Marquer comme lu |
| `POST` | `/api/notifications/mark-all-read` | Authentifié | **Endpoint Symfony custom** (controller dédié, pas une opération API Platform) |
| `GET` | `/api/notifications/unread-count` | Authentifié | Retourne le count |
**Nettoyage** : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours). Pas implémenté dans la première version.
### TaskDocument
- Les endpoints existants restent, avec ajout du filtre `clientTicket`
- Le Processor accepte `task` OU `clientTicket`
- Sécurité : ROLE_ADMIN (tous), ROLE_USER (documents liés à une task), ROLE_CLIENT (documents liés à un ticket dont l'utilisateur est le `submittedBy`)
## State Providers & Processors
### `ClientTicketProvider`
- ROLE_CLIENT : filtre par `submittedBy` = utilisateur courant
- ROLE_ADMIN : retourne tous les tickets
- Vérifie que l'utilisateur-client a accès au projet du ticket (via `allowedProjects`)
### `ClientTicketNumberProcessor`
- Sur `POST` : auto-génère le numéro via `SELECT MAX(number) FROM client_ticket WHERE project_id = :project` + 1, avec contrainte unique `(project_id, number)` et retry en cas de conflit
- Valide que `user.client` n'est pas null (empêche la création par un admin même si ROLE_ADMIN hérite de ROLE_CLIENT)
- Set `submittedBy` depuis le token JWT courant
- Set `status` à `new`
- Set `createdAt` et `updatedAt`
### `ClientTicketStatusProcessor`
- Sur `PATCH` : valide la transition de statut
- Transitions interdites : `done``new`, `rejected``new`
- `statusComment` obligatoire si le nouveau statut est `rejected`
- Met à jour `updatedAt`
### `ClientTicketNotificationProcessor`
- Sur `POST` (ticket créé) : crée une `Notification` pour tous les utilisateurs ROLE_ADMIN
- Type : `ticket_created`
- Title : "Nouveau ticket client CT-XXX"
- Message : titre du ticket + nom du projet
- Sur `PATCH` (changement de statut) : crée une `Notification` pour le `submittedBy`
- Type : `ticket_status_changed`
- Title : "Ticket CT-XXX mis à jour"
- Message : nouveau statut + `statusComment` si présent
### `NotificationProvider`
- Toujours filtré par l'utilisateur courant (`user` = token JWT)
- Paginé : 30 résultats par page
- Endpoint `unread-count` : `SELECT COUNT(*) WHERE user = :user AND isRead = false`
### `MarkAllReadController`
Endpoint custom Symfony (`POST /api/notifications/mark-all-read`) :
- Récupère l'utilisateur depuis le token JWT
- Exécute `UPDATE notification SET is_read = true WHERE user_id = :user AND is_read = false`
- Retourne `204 No Content`
## Frontend
### Routing & Middleware
Modification de `auth.global.ts` :
- ROLE_CLIENT → redirigé vers `/portal`, accès bloqué à `/projects`, `/admin`, `/time-tracking`, etc.
- ROLE_ADMIN / ROLE_USER → peut accéder à `/portal` pour voir la vue côté client
### Pages du portail
#### `/portal` — Liste des projets
- Affiche les projets auxquels l'utilisateur-client a accès (`allowedProjects`)
- Cartes simples : nom du projet, nombre de tickets ouverts
- Clic → `/portal/projects/{id}`
#### `/portal/projects/{id}` — Tickets d'un projet
- Liste des tickets soumis sur ce projet
- Pour chaque ticket : numéro (CT-XXX), type badge, titre, statut badge, date de création
- Bouton "Nouveau ticket" → `/portal/projects/{id}/new-ticket`
- Clic sur un ticket → modale de détail (lecture seule : titre, description, url, statut, statusComment, documents)
#### `/portal/projects/{id}/new-ticket` — Formulaire de création
- Select type : `bug`, `improvement`, `other`
- Champ title (requis)
- Champ description (requis, textarea)
- Champ url (affiché uniquement si `type = bug`)
- Zone d'upload de documents (réutilise les composants TaskDocument existants)
- Bouton soumettre
### Modifications des pages existantes
#### Kanban (`/projects/{id}`)
- Icône `heroicons:user-circle` affichée à côté du titre de la task si `task.clientTicket` est set
- Tooltip au survol : "Lié au ticket client CT-XXX" (données disponibles via `task:read`)
#### `/my-tasks`
- Même icône et tooltip que le kanban
#### `/admin` — Nouvel onglet "Tickets client"
- Liste de tous les tickets, avec filtres par projet et statut
- Pour chaque ticket : numéro, type, titre, statut, projet, soumis par, date
- Actions :
- Changer le statut (select + champ statusComment si rejection)
- Voir le détail du ticket (modale avec documents)
### Services API
#### `frontend/services/client-tickets.ts`
```typescript
getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]>
getById(id: number): Promise<ClientTicket>
create(data: { type: string; title: string; description: string; url?: string; project: string }): Promise<ClientTicket>
updateStatus(id: number, data: { status: string; statusComment?: string }): Promise<ClientTicket>
remove(id: number): Promise<void>
```
#### `frontend/services/notifications.ts`
```typescript
getAll(page?: number): Promise<Notification[]>
markAsRead(id: number): Promise<void>
markAllAsRead(): Promise<void>
getUnreadCount(): Promise<number>
```
### DTOs TypeScript
#### `frontend/services/dto/client-ticket.ts`
```typescript
type ClientTicketType = 'bug' | 'improvement' | 'other'
type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
type ClientTicket = {
'@id'?: string
id: number
number: number
type: ClientTicketType
title: string
description: string
url: string | null
status: ClientTicketStatus
statusComment: string | null
project: string // IRI
submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
createdAt: string
updatedAt: string
documents?: TaskDocument[]
}
```
#### `frontend/services/dto/notification.ts`
```typescript
type NotificationType = 'ticket_created' | 'ticket_status_changed'
type Notification = {
'@id'?: string
id: number
user: string // IRI
type: NotificationType
title: string
message: string
relatedTicket: string | null // IRI
isRead: boolean
createdAt: string
}
```
### Composants réutilisés
- `TaskDocumentUpload` → généralisé avec prop `clientTicketId` comme alternative à `taskId`
- `TaskDocumentList` + `TaskDocumentPreview` → réutilisés dans la modale de détail du ticket
### Composants à créer
#### `frontend/components/notification/NotificationBell.vue`
- Placé dans le header de la navbar
- Icône cloche avec badge rouge (nombre de notifications non lues)
- Clic → dropdown avec les notifications récentes (paginé, 30 par page)
- Chaque notification : titre, message (tronqué), date relative, indicateur lu/non-lu
- Clic sur une notification → marque comme lue + navigation vers le ticket lié
- Bouton "Tout marquer comme lu"
### Composable `useNotifications()`
```typescript
const useNotifications = () => {
const unreadCount: Ref<number>
const notifications: Ref<Notification[]>
const fetchNotifications: (page?: number) => Promise<void>
const fetchUnreadCount: () => Promise<void>
const markAsRead: (id: number) => Promise<void>
const markAllAsRead: () => Promise<void>
// Polling toutes les 2 minutes
const startPolling: () => void
const stopPolling: () => void
}
```
Le polling démarre au montage de `NotificationBell` et s'arrête au démontage.
### Clés i18n
Ajouter dans `frontend/i18n/locales/fr.json` (français uniquement pour le moment) :
```
# Portal
portal.title → "Portail client"
portal.projects → "Mes projets"
portal.openTickets → "tickets ouverts"
portal.newTicket → "Nouveau ticket"
portal.ticketDetail → "Détail du ticket"
# Client Ticket
clientTicket.type.bug → "Bug"
clientTicket.type.improvement → "Amélioration"
clientTicket.type.other → "Autre"
clientTicket.status.new → "Nouveau"
clientTicket.status.in_progress → "En cours"
clientTicket.status.done → "Terminé"
clientTicket.status.rejected → "Rejeté"
clientTicket.title → "Titre"
clientTicket.description → "Description"
clientTicket.url → "URL (page concernée)"
clientTicket.statusComment → "Commentaire de statut"
clientTicket.created → "Ticket créé"
clientTicket.statusChanged → "Statut mis à jour"
clientTicket.confirmDelete → "Supprimer ce ticket ?"
clientTicket.linkedTooltip → "Lié au ticket client {number}"
clientTicket.rejectionRequired → "Un commentaire est requis pour rejeter un ticket"
# Notifications
notification.title → "Notifications"
notification.markAllRead → "Tout marquer comme lu"
notification.empty → "Aucune notification"
notification.ticketCreated → "Nouveau ticket client {number}"
notification.ticketStatusChanged → "Ticket {number} mis à jour"
```
## Migration
### Nouvelles tables
**`client_ticket`** :
- Colonnes correspondant à l'entité `ClientTicket`
- Index sur `project_id`
- Index sur `submitted_by_id`
- Index composite sur `(status, project_id)` pour les filtres admin
- Contrainte unique sur `(project_id, number)` pour la numérotation par projet
- FK `project_id``project.id` ON DELETE CASCADE
- FK `submitted_by_id``user.id` **ON DELETE SET NULL**
**`notification`** :
- Colonnes correspondant à l'entité `Notification`
- Index sur `user_id`
- Index composite sur `(user_id, is_read)` pour le count non-lu
- FK `user_id``user.id` ON DELETE CASCADE
- FK `related_ticket_id``client_ticket.id` ON DELETE SET NULL
**`user_allowed_projects`** (table de jointure ManyToMany) :
- `user_id``user.id` ON DELETE CASCADE
- `project_id``project.id` ON DELETE CASCADE
### Modifications de tables existantes
**`user`** :
- Ajout colonne `client_id` (nullable) — FK → `client.id` ON DELETE SET NULL
**`task`** :
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE SET NULL
**`task_document`** (table conservée, pas de renommage) :
- Colonne `task_id` devient nullable
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE CASCADE
- Contrainte CHECK : `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`
## Sécurité
### Hiérarchie des rôles
```yaml
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
```
### Contrôle d'accès
| Ressource | ROLE_CLIENT | ROLE_USER | ROLE_ADMIN |
|-----------|-------------|-----------|------------|
| ClientTicket (ses propres) | Lecture + Création | Lecture via `task:read` (champ `task.clientTicket`) | CRUD complet |
| ClientTicket collection `/api/client_tickets` | Ses propres tickets | — | Tous |
| Notification (ses propres) | Lecture + Mark as read | Lecture + Mark as read | Lecture + Mark as read |
| TaskDocument (lié à une task) | — | Lecture | CRUD complet |
| TaskDocument (lié à un ticket) | Lecture + Upload (si `submittedBy` = soi) | — | CRUD complet |
| Task, Project, Client, TimeEntry, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup | — | Accès normal (`is_granted('ROLE_USER')`) | Accès normal |
| Pages /portal | Accès | Accès | Accès |
| Pages /projects, /admin | — | Accès | Accès |
### Validation du Provider ClientTicket
- ROLE_CLIENT : vérifie que le projet du ticket fait partie de `allowedProjects` de l'utilisateur
- ROLE_CLIENT : ne peut voir que les tickets où `submittedBy` = lui-même
- ROLE_ADMIN : aucune restriction
### Validation du Processor ClientTicket (POST)
- Vérifie que `user.client` n'est pas null — un utilisateur admin ne peut pas créer de ticket même s'il hérite de ROLE_CLIENT via la hiérarchie de rôles
## Phases de livraison
### Phase 1 — Fondations
1. **Prérequis sécurité** : modifier `User::getRoles()` pour ne plus ajouter `ROLE_USER` aux utilisateurs `ROLE_CLIENT` ; ajouter `security: "is_granted('ROLE_USER')"` sur les opérations GetCollection/Get de Task, Project, Client, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry
2. Modifier `User` : ajouter `client` (ManyToOne → Client, nullable), `allowedProjects` (ManyToMany → Project), rôle `ROLE_CLIENT`, groupes de sérialisation `me:read`, `user:read`, `user:write`
3. Généraliser `TaskDocument` : `task` devient nullable, ajout `clientTicket` (ManyToOne → ClientTicket, nullable), contrainte CHECK, Processor généralisé
4. Créer l'entité `ClientTicket` + migration (avec contrainte unique `(project_id, number)`)
5. API CRUD `ClientTicket` avec sécurité (Provider, Processor, validation `user.client` sur POST, validation des transitions de statut sur PATCH)
6. Admin : gestion des utilisateurs-clients (créer un user avec ROLE_CLIENT, lié à un client + projets autorisés)
### Phase 2 — Portail client
1. Pages `/portal`, `/portal/projects/{id}`, formulaire de création de ticket
2. Upload de documents sur les tickets (réutilisation des composants TaskDocument existants, généralisés avec prop `clientTicketId`)
3. Lien `Task.clientTicket` + icône dans le kanban et `/my-tasks` (données via `task:read`)
4. Admin : onglet tickets client (liste, changement de statut)
### Phase 3 — Notifications
1. Entité `Notification` + API (paginé, 30 par page)
2. `MarkAllReadController` — endpoint Symfony custom (`POST /api/notifications/mark-all-read`)
3. Auto-création des notifications dans le `ClientTicketNotificationProcessor`
4. `NotificationBell.vue` avec polling toutes les 2 minutes
5. Composable `useNotifications()`
6. Note : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours)