From efc3742fff72c082c7cf6182c8a53aff0ed057fb Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 08:49:18 +0100 Subject: [PATCH] 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) --- .../specs/2026-03-15-task-documents-design.md | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-task-documents-design.md b/docs/superpowers/specs/2026-03-15-task-documents-design.md index 2515f15..6154beb 100644 --- a/docs/superpowers/specs/2026-03-15-task-documents-design.md +++ b/docs/superpowers/specs/2026-03-15-task-documents-design.md @@ -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 -upload(taskId: number, file: File): Promise // POST multipart, un fichier à la fois +upload(taskId: number, file: File): Promise remove(id: number): Promise -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.