524 lines
22 KiB
Markdown
524 lines
22 KiB
Markdown
# 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)
|