# 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)