Files
Lesstime/docs/superpowers/specs/2026-03-15-task-documents-design.md
matthieu efc3742fff 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>
2026-03-15 08:49:18 +01:00

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, 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 :

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 :
    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_idtask.id ON DELETE CASCADE
  • Clé étrangère uploaded_by_iduser.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.