From f5e41bc3776b481f392587ca5fba8d2ce17991c7 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 18:54:49 +0100 Subject: [PATCH] docs : add client portal design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-client-portal-design.md | 523 ++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-client-portal-design.md diff --git a/docs/superpowers/specs/2026-03-15-client-portal-design.md b/docs/superpowers/specs/2026-03-15-client-portal-design.md new file mode 100644 index 0000000..e9d599e --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-client-portal-design.md @@ -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 +getById(id: number): Promise +create(data: { type: string; title: string; description: string; url?: string; project: string }): Promise +updateStatus(id: number, data: { status: string; statusComment?: string }): Promise +remove(id: number): Promise +``` + +#### `frontend/services/notifications.ts` + +```typescript +getAll(page?: number): Promise +markAsRead(id: number): Promise +markAllAsRead(): Promise +getUnreadCount(): Promise +``` + +### 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 + const notifications: Ref + + const fetchNotifications: (page?: number) => Promise + const fetchUnreadCount: () => Promise + const markAsRead: (id: number) => Promise + const markAllAsRead: () => Promise + + // 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)