docs : update task documents spec after review
Address review findings: add EntityListener for file cleanup on cascade delete, dedicated download endpoint, sequential upload, i18n keys, .gitignore entry, and error handling strategy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs
|
||||
- **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
|
||||
|
||||
@@ -29,15 +30,39 @@ Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs
|
||||
|
||||
### Relation inverse sur Task
|
||||
|
||||
- `Task.documents` : OneToMany → TaskDocument
|
||||
- `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
|
||||
- Servir les fichiers via Nginx (`/uploads/{fileName}`) ou un endpoint Symfony dédié pour contrôle d'accès
|
||||
- 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
|
||||
|
||||
@@ -46,6 +71,7 @@ Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs
|
||||
| `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`)
|
||||
@@ -54,14 +80,14 @@ Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs
|
||||
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. Crée et persiste l'entité `TaskDocument` avec toutes les métadonnées
|
||||
6. Set `uploadedBy` depuis le token JWT courant
|
||||
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. Récupère le `fileName` de l'entité
|
||||
2. Supprime le fichier du filesystem (`var/uploads/documents/{fileName}`)
|
||||
3. Supprime l'entité de la base de données
|
||||
1. Supprime l'entité de la base de données
|
||||
2. Le nettoyage du fichier est géré automatiquement par le `TaskDocumentListener.preRemove`
|
||||
|
||||
### Validation
|
||||
|
||||
@@ -88,17 +114,18 @@ Tous dans `frontend/components/task/` :
|
||||
#### `TaskDocumentUpload.vue`
|
||||
|
||||
- Zone drag & drop avec bordure pointillée
|
||||
- Texte : "Glisser des fichiers ici ou cliquer pour sélectionner"
|
||||
- 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 ou parallèle (un POST multipart par fichier)
|
||||
- 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 du fichier
|
||||
- **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
|
||||
@@ -124,19 +151,21 @@ Tous dans `frontend/components/task/` :
|
||||
`frontend/services/task-documents.ts` :
|
||||
|
||||
```typescript
|
||||
// Fonctions du service
|
||||
getByTask(taskId: number): Promise<TaskDocument[]>
|
||||
upload(taskId: number, file: File): Promise<TaskDocument> // POST multipart, un fichier à la fois
|
||||
upload(taskId: number, file: File): Promise<TaskDocument>
|
||||
remove(id: number): Promise<void>
|
||||
getFileUrl(fileName: string): string // Construit l'URL publique du fichier
|
||||
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
|
||||
@@ -144,10 +173,24 @@ type TaskDocument = {
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: string // IRI
|
||||
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`
|
||||
@@ -166,6 +209,10 @@ type TaskDocument = {
|
||||
|
||||
## Docker
|
||||
|
||||
- Ajouter un volume dans `docker-compose.yml` pour `var/uploads/` afin de persister les fichiers
|
||||
- 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`
|
||||
- Ajouter une location Nginx pour servir `/uploads/` si accès direct souhaité
|
||||
|
||||
## .gitignore
|
||||
|
||||
Ajouter `var/uploads/` dans `.gitignore` pour éviter de committer des fichiers uploadés en dev local.
|
||||
|
||||
Reference in New Issue
Block a user