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