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>
9.0 KiB
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, aveccascade: ['remove']côté Doctrine- Sérialisé dans le groupe
task:readpour 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énementpreRemove - Récupère le
fileNamede l'entité et supprime le fichier devar/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 :
- Suppression directe d'un document via
DELETE /api/task_documents/{id} - 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
fileNameinterne dans l'URL — utilise l'iddu 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)
- Reçoit le fichier via multipart/form-data + IRI de la task
- Valide : fichier non vide, taille ≤ 50 Mo
- Génère un UUID v4, extrait l'extension du nom original
- Déplace le fichier uploadé dans
var/uploads/documents/{uuid}.{ext} - Si le déplacement du fichier échoue, throw une exception — ne pas persister l'entité
- Crée et persiste l'entité
TaskDocumentavec toutes les métadonnées - Set
uploadedBydepuis le token JWT courant
State Processor — DELETE
- Supprime l'entité de la base de données
- 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_filesizeetpost_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,droppour 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 enobject-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"
- Images (
- 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 :
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 :
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 :
TaskDocumentUpload(si mode édition, ROLE_ADMIN)TaskDocumentList(toujours visible, passe les documents du ticket)
TaskDocumentPreviewmonté conditionnellement (v-if sur document sélectionné)- Chargement des documents : via la relation
task.documentsdéjà sérialisée, ou appel séparé au service
Migration
- Nouvelle table
task_documentavec les colonnes correspondant à l'entité - Index sur
task_idpour les requêtes filtrées - Clé étrangère
task_id→task.idON DELETE CASCADE - Clé étrangère
uploaded_by_id→user.idON DELETE SET NULL
Docker
- Ajouter un volume nommé dans
docker-compose.ymlpourvar/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_filesizeetpost_max_size
.gitignore
Ajouter var/uploads/ dans .gitignore pour éviter de committer des fichiers uploadés en dev local.