22 KiB
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é
TaskDocumentn'est pas renommée, elle est généralisée avec un champclientTicketnullable - 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 :
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
taskOUclientTicket - 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 viaSELECT 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.clientn'est pas null (empêche la création par un admin même si ROLE_ADMIN hérite de ROLE_CLIENT) - Set
submittedBydepuis le token JWT courant - Set
statusànew - Set
createdAtetupdatedAt
ClientTicketStatusProcessor
- Sur
PATCH: valide la transition de statut - Transitions interdites :
done→new,rejected→new statusCommentobligatoire si le nouveau statut estrejected- Met à jour
updatedAt
ClientTicketNotificationProcessor
- Sur
POST(ticket créé) : crée uneNotificationpour tous les utilisateurs ROLE_ADMIN- Type :
ticket_created - Title : "Nouveau ticket client CT-XXX"
- Message : titre du ticket + nom du projet
- Type :
- Sur
PATCH(changement de statut) : crée uneNotificationpour lesubmittedBy- Type :
ticket_status_changed - Title : "Ticket CT-XXX mis à jour"
- Message : nouveau statut +
statusCommentsi présent
- Type :
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 à
/portalpour 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-circleaffichée à côté du titre de la task sitask.clientTicketest 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
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
getAll(page?: number): Promise<Notification[]>
markAsRead(id: number): Promise<void>
markAllAsRead(): Promise<void>
getUnreadCount(): Promise<number>
DTOs TypeScript
frontend/services/dto/client-ticket.ts
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
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 propclientTicketIdcomme alternative àtaskIdTaskDocumentList+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()
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.idON DELETE CASCADE - FK
submitted_by_id→user.idON 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.idON DELETE CASCADE - FK
related_ticket_id→client_ticket.idON DELETE SET NULL
user_allowed_projects (table de jointure ManyToMany) :
user_id→user.idON DELETE CASCADEproject_id→project.idON DELETE CASCADE
Modifications de tables existantes
user :
- Ajout colonne
client_id(nullable) — FK →client.idON DELETE SET NULL
task :
- Ajout colonne
client_ticket_id(nullable) — FK →client_ticket.idON DELETE SET NULL
task_document (table conservée, pas de renommage) :
- Colonne
task_iddevient nullable - Ajout colonne
client_ticket_id(nullable) — FK →client_ticket.idON DELETE CASCADE - Contrainte CHECK :
task_id IS NOT NULL OR client_ticket_id IS NOT NULL
Sécurité
Hiérarchie des rôles
# 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
allowedProjectsde 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.clientn'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
- Prérequis sécurité : modifier
User::getRoles()pour ne plus ajouterROLE_USERaux utilisateursROLE_CLIENT; ajoutersecurity: "is_granted('ROLE_USER')"sur les opérations GetCollection/Get de Task, Project, Client, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry - Modifier
User: ajouterclient(ManyToOne → Client, nullable),allowedProjects(ManyToMany → Project), rôleROLE_CLIENT, groupes de sérialisationme:read,user:read,user:write - Généraliser
TaskDocument:taskdevient nullable, ajoutclientTicket(ManyToOne → ClientTicket, nullable), contrainte CHECK, Processor généralisé - Créer l'entité
ClientTicket+ migration (avec contrainte unique(project_id, number)) - API CRUD
ClientTicketavec sécurité (Provider, Processor, validationuser.clientsur POST, validation des transitions de statut sur PATCH) - Admin : gestion des utilisateurs-clients (créer un user avec ROLE_CLIENT, lié à un client + projets autorisés)
Phase 2 — Portail client
- Pages
/portal,/portal/projects/{id}, formulaire de création de ticket - Upload de documents sur les tickets (réutilisation des composants TaskDocument existants, généralisés avec prop
clientTicketId) - Lien
Task.clientTicket+ icône dans le kanban et/my-tasks(données viatask:read) - Admin : onglet tickets client (liste, changement de statut)
Phase 3 — Notifications
- Entité
Notification+ API (paginé, 30 par page) MarkAllReadController— endpoint Symfony custom (POST /api/notifications/mark-all-read)- Auto-création des notifications dans le
ClientTicketNotificationProcessor NotificationBell.vueavec polling toutes les 2 minutes- Composable
useNotifications() - Note : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours)