# Task Documents — Design Spec

## Overview

Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs peuvent uploader des fichiers via drag & drop ou sélection, les visualiser (images, PDF) dans une modale plein écran, et les télécharger.

## Contraintes

- **Taille max par fichier** : 50 Mo
- **Types acceptés** : tous types de fichiers
- **Nombre par ticket** : illimité
- **Stockage** : filesystem local (`var/uploads/documents/`)
- **Permissions** : ROLE_ADMIN pour créer/supprimer, ROLE_USER pour lire
- **Contexte** : application single-tenant, tous les utilisateurs voient tous les projets — pas de scoping projet

## Backend

### Entité `TaskDocument`

| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `task` | ManyToOne → Task | Ticket parent (CASCADE on delete) |
| `originalName` | string (255) | Nom original du fichier uploadé |
| `fileName` | string (255) | Nom unique sur disque (`{uuid}.{extension}`) |
| `mimeType` | string (100) | Type MIME (ex: `image/png`, `application/pdf`) |
| `size` | int | Taille en octets |
| `createdAt` | DateTimeImmutable | Date d'upload |
| `uploadedBy` | ManyToOne → User | Utilisateur ayant uploadé (SET NULL on delete) |

### Relation inverse sur Task

- `Task.documents` : OneToMany → TaskDocument, avec `cascade: ['remove']` côté Doctrine
- Sérialisé dans le groupe `task:read` pour charger les documents avec le ticket

### Nettoyage des fichiers à la suppression

Quand un `TaskDocument` est supprimé (directement ou par cascade depuis Task), le fichier physique doit aussi être supprimé. Stratégie :

- **Doctrine EntityListener** (`TaskDocumentListener`) avec événement `preRemove`
- Récupère le `fileName` de l'entité et supprime le fichier de `var/uploads/documents/`
- Si le fichier n'existe pas sur disque (déjà supprimé manuellement), log un warning et continue sans erreur

Ceci couvre les deux cas :
1. Suppression directe d'un document via `DELETE /api/task_documents/{id}`
2. Suppression en cascade quand une Task est supprimée

### Stockage filesystem

- Répertoire : `var/uploads/documents/`
- Nommage : `{uuid}.{extension}` — évite les collisions et les caractères spéciaux
- Volume Docker dédié pour persister les uploads
- Ajouter `var/uploads/` dans `.gitignore`

### Téléchargement des fichiers

Endpoint dédié Symfony servi via un State Provider :

| Méthode | Route | Description | Accès |
|---------|-------|-------------|-------|
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier (BinaryFileResponse) | ROLE_USER |

- Contrôle d'accès via authentification JWT (pas d'accès anonyme)
- Retourne le fichier avec les headers `Content-Disposition` (inline pour images/PDF, attachment pour les autres)
- Le frontend n'expose jamais le `fileName` interne dans l'URL — utilise l'`id` du document

### API Endpoints

| Méthode | Route | Description | Accès |
|---------|-------|-------------|-------|
| `POST` | `/api/task_documents` | Upload multipart/form-data | ROLE_ADMIN |
| `GET` | `/api/task_documents?task=/api/tasks/{id}` | Liste documents d'un ticket | ROLE_USER |
| `GET` | `/api/task_documents/{id}` | Métadonnées d'un document | ROLE_USER |
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier | ROLE_USER |
| `DELETE` | `/api/task_documents/{id}` | Supprime document + fichier | ROLE_ADMIN |

### State Processor — POST (`TaskDocumentProcessor`)

1. Reçoit le fichier via multipart/form-data + IRI de la task
2. Valide : fichier non vide, taille ≤ 50 Mo
3. Génère un UUID v4, extrait l'extension du nom original
4. Déplace le fichier uploadé dans `var/uploads/documents/{uuid}.{ext}`
5. Si le déplacement du fichier échoue, throw une exception — ne pas persister l'entité
6. Crée et persiste l'entité `TaskDocument` avec toutes les métadonnées
7. Set `uploadedBy` depuis le token JWT courant

### State Processor — DELETE

1. Supprime l'entité de la base de données
2. Le nettoyage du fichier est géré automatiquement par le `TaskDocumentListener.preRemove`

### Validation

- Contrainte sur `originalName` : NotBlank
- Contrainte sur `task` : NotNull
- Validation dans le Processor : taille fichier ≤ 50 Mo, fichier présent dans la requête
- PHP `upload_max_filesize` et `post_max_size` à configurer ≥ 50 Mo

### Configuration PHP/Nginx

- `php.ini` : `upload_max_filesize = 50M`, `post_max_size = 55M`
- Nginx : `client_max_body_size 55m;`

## Frontend

### Placement dans l'UI

La zone de documents est placée **sous la description** dans le `TaskModal`, visible en mode édition.

### Composants à créer

Tous dans `frontend/components/task/` :

#### `TaskDocumentUpload.vue`

- Zone drag & drop avec bordure pointillée
- Texte : "Glisser des fichiers ici ou cliquer pour sélectionner" (clé i18n : `taskDocuments.dropzone`)
- Input file caché (`multiple`, `accept="*"`)
- Événements : `dragover`, `dragleave`, `drop` pour le feedback visuel
- Barre de progression par fichier pendant l'upload
- Upload **séquentiel** (un POST multipart par fichier, un à la fois) — plus simple et prévisible pour les progress bars
- Émet un événement quand l'upload est terminé pour rafraîchir la liste

#### `TaskDocumentList.vue`

- Grille de cartes compactes pour chaque document
- **Images** (`image/*`) : miniature 64x64 en `object-fit: cover`, chargée depuis l'URL de download
  - Note : les images sont chargées en pleine résolution pour les miniatures. C'est une limitation acceptée — la génération de thumbnails côté serveur pourra être ajoutée ultérieurement si besoin.
- **Autres fichiers** : icône selon le type MIME :
  - PDF → icône PDF
  - Word/Excel → icônes Office
  - Archives → icône archive
  - Défaut → icône fichier générique
- Informations affichées : nom original (tronqué si > ~30 chars), taille formatée (Ko/Mo)
- Clic sur un document → ouvre `TaskDocumentPreview`
- Bouton supprimer (visible uniquement pour ROLE_ADMIN, avec confirmation)

#### `TaskDocumentPreview.vue`

- Modale plein écran (overlay sombre semi-transparent)
- Contenu selon le type :
  - **Images** (`image/*`) : `<img>` centré, taille adaptative
  - **PDF** (`application/pdf`) : `<iframe>` intégré
  - **Autres** : grande icône + nom du fichier + taille + bouton "Télécharger"
- Navigation : flèches gauche/droite pour parcourir les documents du ticket
- Fermeture : bouton X en haut à droite, clic sur l'overlay, touche Escape
- Raccourcis clavier : flèches pour naviguer, Escape pour fermer

### Service API

`frontend/services/task-documents.ts` :

```typescript
getByTask(taskId: number): Promise<TaskDocument[]>
upload(taskId: number, file: File): Promise<TaskDocument>
remove(id: number): Promise<void>
getDownloadUrl(id: number): string  // Retourne `/api/task_documents/{id}/download`
```

**Note upload :** la fonction `upload` ne peut pas utiliser `useApi().post()` directement car celui-ci set `Content-Type: application/json`. L'upload doit utiliser `$fetch` directement avec un `FormData` comme body et ne PAS setter de `Content-Type` (le navigateur le fait automatiquement avec le boundary multipart).

### DTO TypeScript

`frontend/services/dto/task-document.ts` :

```typescript
type TaskDocument = {
    '@id'?: string
    id: number
    task: string           // IRI
    originalName: string
    fileName: string
    mimeType: string
    size: number
    createdAt: string
    uploadedBy: string | null  // IRI ou null si user supprimé
}
```

### Clés i18n

Ajouter dans `frontend/i18n/locales/` :

```
taskDocuments.dropzone          → "Glisser des fichiers ici ou cliquer pour sélectionner"
taskDocuments.uploaded          → "Document uploadé"
taskDocuments.deleted           → "Document supprimé"
taskDocuments.uploadError       → "Erreur lors de l'upload"
taskDocuments.confirmDelete     → "Supprimer ce document ?"
taskDocuments.download          → "Télécharger"
taskDocuments.documents         → "Documents"
```

### Intégration dans TaskModal

- Import des 3 composants dans `TaskModal.vue`
- Sous le champ description :
  1. `TaskDocumentUpload` (si mode édition, ROLE_ADMIN)
  2. `TaskDocumentList` (toujours visible, passe les documents du ticket)
- `TaskDocumentPreview` monté conditionnellement (v-if sur document sélectionné)
- Chargement des documents : via la relation `task.documents` déjà sérialisée, ou appel séparé au service

## Migration

- Nouvelle table `task_document` avec les colonnes correspondant à l'entité
- Index sur `task_id` pour les requêtes filtrées
- Clé étrangère `task_id` → `task.id` ON DELETE CASCADE
- Clé étrangère `uploaded_by_id` → `user.id` ON DELETE SET NULL

## Docker

- Ajouter un volume nommé dans `docker-compose.yml` pour `var/uploads/` afin de persister les fichiers
- Le volume est monté dans le service PHP uniquement (pas besoin dans Nginx car les fichiers sont servis via Symfony)
- Vérifier la config PHP pour `upload_max_filesize` et `post_max_size`

## .gitignore

Ajouter `var/uploads/` dans `.gitignore` pour éviter de committer des fichiers uploadés en dev local.
