Files
Lesstime/docs/superpowers/specs/2026-03-15-client-portal-design.md
matthieu f5e41bc377 docs : add client portal design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:54:49 +01:00

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é 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 :

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 :

  • donenew (interdit)
  • rejectednew (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 : donenew, rejectednew
  • 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

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

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_idproject.id ON DELETE CASCADE
  • FK submitted_by_iduser.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_iduser.id ON DELETE CASCADE
  • FK related_ticket_idclient_ticket.id ON DELETE SET NULL

user_allowed_projects (table de jointure ManyToMany) :

  • user_iduser.id ON DELETE CASCADE
  • project_idproject.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

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