# Task Documents Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow users to attach documents to tasks with drag & drop / file selection, preview images and PDFs in a fullscreen modal, and download any file. **Architecture:** New `TaskDocument` entity with API Platform CRUD (multipart POST via custom Processor, download via custom Provider). Doctrine `EntityListener` for file cleanup on delete/cascade. Frontend: 3 components (upload zone, document list, preview modal) integrated into `TaskModal.vue`. **Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS **Spec:** `docs/superpowers/specs/2026-03-15-task-documents-design.md` --- ## Chunk 1: Backend — Entity, Migration, Config ### Task 1: PHP/Nginx upload limits **Files:** - Modify: `docker/php/config/php.ini` - Modify: `docker/nginx/conf.d/lesstime.conf` - [ ] **Step 1: Add upload limits to php.ini** Append to `docker/php/config/php.ini`: ```ini [Upload] upload_max_filesize = 50M post_max_size = 55M ``` - [ ] **Step 2: Add client_max_body_size to Nginx** Add `client_max_body_size 55m;` inside the `server` block of `docker/nginx/conf.d/lesstime.conf`, after `index index.html;`: ```nginx client_max_body_size 55m; ``` - [ ] **Step 3: Restart containers to apply config** ```bash docker restart php-lesstime-fpm nginx-lesstime ``` - [ ] **Step 4: Commit** ```bash git add docker/php/config/php.ini docker/nginx/conf.d/lesstime.conf git commit -m "feat(config) : set upload limits to 50MB for task documents" ``` --- ### Task 1b: Docker volume for uploads persistence **Files:** - Modify: `docker-compose.yml` - [ ] **Step 1: Add named volume for uploads** In `docker-compose.yml`, add a named volume `uploads_data` and mount it in the `php` service: Under `php.volumes`, add: ```yaml - uploads_data:/var/www/html/var/uploads ``` Under top-level `volumes`, add: ```yaml uploads_data: ``` - [ ] **Step 2: Restart containers** ```bash docker compose down && docker compose up -d ``` - [ ] **Step 3: Commit** ```bash git add docker-compose.yml git commit -m "feat(docker) : add named volume for document uploads persistence" ``` --- ### Task 2: TaskDocument entity **Files:** - Create: `src/Entity/TaskDocument.php` - [ ] **Step 1: Create the TaskDocument entity** ```php ['task_document:read']], denormalizationContext: ['groups' => ['task_document:write']], order: ['id' => 'DESC'], )] #[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])] #[ORM\Entity] #[ORM\EntityListeners([\App\EventListener\TaskDocumentListener::class])] class TaskDocument { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['task_document:read', 'task:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Groups(['task_document:read', 'task_document:write'])] private ?Task $task = null; #[ORM\Column(length: 255)] #[Groups(['task_document:read', 'task:read'])] private ?string $originalName = null; #[ORM\Column(length: 255)] #[Groups(['task_document:read', 'task:read'])] private ?string $fileName = null; #[ORM\Column(length: 100)] #[Groups(['task_document:read', 'task:read'])] private ?string $mimeType = null; #[ORM\Column] #[Groups(['task_document:read', 'task:read'])] private ?int $size = null; #[ORM\Column(type: 'datetime_immutable')] #[Groups(['task_document:read', 'task:read'])] private ?\DateTimeImmutable $createdAt = null; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Groups(['task_document:read', 'task:read'])] private ?User $uploadedBy = null; public function getId(): ?int { return $this->id; } public function getTask(): ?Task { return $this->task; } public function setTask(?Task $task): static { $this->task = $task; return $this; } public function getOriginalName(): ?string { return $this->originalName; } public function setOriginalName(string $originalName): static { $this->originalName = $originalName; return $this; } public function getFileName(): ?string { return $this->fileName; } public function setFileName(string $fileName): static { $this->fileName = $fileName; return $this; } public function getMimeType(): ?string { return $this->mimeType; } public function setMimeType(string $mimeType): static { $this->mimeType = $mimeType; return $this; } public function getSize(): ?int { return $this->size; } public function setSize(int $size): static { $this->size = $size; return $this; } public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(\DateTimeImmutable $createdAt): static { $this->createdAt = $createdAt; return $this; } public function getUploadedBy(): ?User { return $this->uploadedBy; } public function setUploadedBy(?User $uploadedBy): static { $this->uploadedBy = $uploadedBy; return $this; } } ``` - [ ] **Step 2: Add `documents` relation to Task entity** In `src/Entity/Task.php`, add the `documents` OneToMany collection: 1. Add import: `use Doctrine\Common\Collections\Collection;` (already present) 2. Add property after `$archived`: ```php /** @var Collection */ #[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])] #[Groups(['task:read'])] private Collection $documents; ``` 3. In constructor, add: `$this->documents = new ArrayCollection();` 4. Add getter: ```php /** @return Collection */ public function getDocuments(): Collection { return $this->documents; } ``` - [ ] **Step 3: Commit** ```bash git add src/Entity/TaskDocument.php src/Entity/Task.php git commit -m "feat : add TaskDocument entity with Task relation" ``` --- ### Task 3: Generate and run migration **Files:** - Create: `migrations/VersionXXX.php` (auto-generated) - [ ] **Step 1: Generate migration** ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff ``` - [ ] **Step 2: Review the generated migration** Read the file and verify it creates `task_document` table with correct columns, indexes, and foreign keys. - [ ] **Step 3: Run migration** ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction ``` - [ ] **Step 4: Commit** ```bash git add migrations/ git commit -m "feat : add task_document migration" ``` --- ### Task 4: TaskDocumentListener (file cleanup on delete) **Files:** - Create: `src/EventListener/TaskDocumentListener.php` - [ ] **Step 1: Create the listener** ```php uploadDir . '/' . $document->getFileName(); if (file_exists($filePath)) { if (!unlink($filePath)) { $this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]); } } else { $this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]); } } } ``` - [ ] **Step 2: Register the service with uploadDir parameter** Create `config/services_task_document.yaml` or add to `config/services.yaml`: ```yaml # In config/services.yaml, add: parameters: task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' services: App\EventListener\TaskDocumentListener: arguments: $uploadDir: '%task_document_upload_dir%' ``` If `config/services.yaml` already has `parameters:` section, merge into it. If not, add the parameter block. - [ ] **Step 3: Create upload directory** ```bash mkdir -p var/uploads/documents ``` - [ ] **Step 4: Commit** ```bash git add src/EventListener/TaskDocumentListener.php config/services.yaml git commit -m "feat : add TaskDocumentListener for file cleanup on delete" ``` --- ### Task 5: TaskDocumentProcessor (upload handler) **Files:** - Create: `src/State/TaskDocumentProcessor.php` - [ ] **Step 1: Create the processor** ```php */ final readonly class TaskDocumentProcessor implements ProcessorInterface { private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB public function __construct( private EntityManagerInterface $entityManager, private Security $security, private RequestStack $requestStack, private string $uploadDir, ) {} /** * @param TaskDocument $data */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument { $request = $this->requestStack->getCurrentRequest(); if (null === $request) { throw new BadRequestHttpException('No request available.'); } $file = $request->files->get('file'); if (null === $file || !$file->isValid()) { throw new BadRequestHttpException('No valid file uploaded.'); } if ($file->getSize() > self::MAX_FILE_SIZE) { throw new BadRequestHttpException('File size exceeds 50 MB limit.'); } $taskIri = $request->request->get('task'); if (null === $taskIri || '' === $taskIri) { throw new BadRequestHttpException('Task IRI is required.'); } // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) $taskId = (int) basename((string) $taskIri); $task = $this->entityManager->getRepository(Task::class)->find($taskId); if (null === $task) { throw new BadRequestHttpException('Task not found.'); } // Capture file metadata BEFORE move() — move invalidates the temp file $originalName = $file->getClientOriginalName(); $extension = $file->getClientOriginalExtension() ?: 'bin'; $mimeType = $file->getClientMimeType() ?? 'application/octet-stream'; $fileSize = $file->getSize(); $uuid = Uuid::v4()->toRfc4122(); $fileName = $uuid . '.' . $extension; if (!is_dir($this->uploadDir)) { mkdir($this->uploadDir, 0o775, true); } $file->move($this->uploadDir, $fileName); $document = new TaskDocument(); $document->setTask($task); $document->setOriginalName($originalName); $document->setFileName($fileName); $document->setMimeType($mimeType); $document->setSize($fileSize); $document->setCreatedAt(new \DateTimeImmutable()); $document->setUploadedBy($this->security->getUser()); $this->entityManager->persist($document); $this->entityManager->flush(); return $document; } } ``` - [ ] **Step 2: Register uploadDir injection** In `config/services.yaml`, add: ```yaml App\State\TaskDocumentProcessor: arguments: $uploadDir: '%task_document_upload_dir%' ``` - [ ] **Step 3: Commit** ```bash git add src/State/TaskDocumentProcessor.php config/services.yaml git commit -m "feat : add TaskDocumentProcessor for multipart file upload" ``` --- ### Task 6: TaskDocumentDownloadController (file download) **Files:** - Create: `src/Controller/TaskDocumentDownloadController.php` - Modify: `config/routes.yaml` (or create `config/routes/task_document.yaml`) **Why a controller instead of a Provider:** API Platform providers return resource objects that get serialized. A file download needs to return a `BinaryFileResponse` directly, which bypasses API Platform's serialization pipeline. A Symfony controller is the correct approach for binary file serving. - [ ] **Step 1: Create the download controller** ```php entityManager->getRepository(TaskDocument::class)->find($id); if (null === $document) { throw new NotFoundHttpException('Document not found.'); } $filePath = $this->uploadDir . '/' . $document->getFileName(); if (!file_exists($filePath)) { throw new NotFoundHttpException('File not found on disk.'); } $response = new BinaryFileResponse($filePath); $mimeType = $document->getMimeType() ?? 'application/octet-stream'; // Inline for images and PDFs, attachment for everything else $disposition = str_starts_with($mimeType, 'image/') || $mimeType === 'application/pdf' ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT; $response->setContentDisposition($disposition, $document->getOriginalName()); $response->headers->set('Content-Type', $mimeType); return $response; } } ``` - [ ] **Step 2: Register uploadDir injection** In `config/services.yaml`, add: ```yaml App\Controller\TaskDocumentDownloadController: arguments: $uploadDir: '%task_document_upload_dir%' ``` - [ ] **Step 3: Commit** ```bash git add src/Controller/TaskDocumentDownloadController.php config/services.yaml git commit -m "feat : add TaskDocumentDownloadController for file download" ``` --- ### Task 7: Verify backend API - [ ] **Step 1: Clear cache and verify no errors** ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear ``` - [ ] **Step 2: Test upload via curl** ```bash curl -X POST http://localhost:8082/api/task_documents \ -H "Cookie: BEARER=" \ -F "file=@/path/to/test-file.png" \ -F "task=/api/tasks/1" ``` Verify response returns JSON with document metadata. - [ ] **Step 3: Test download** ```bash curl -I http://localhost:8082/api/task_documents/1/download \ -H "Cookie: BEARER=" ``` Verify `Content-Type` and `Content-Disposition` headers. - [ ] **Step 4: Test delete** ```bash curl -X DELETE http://localhost:8082/api/task_documents/1 \ -H "Cookie: BEARER=" ``` Verify document removed from DB and file deleted from disk. --- ## Chunk 2: Frontend — Service, DTO, Components ### Task 8: TypeScript DTO and service **Files:** - Create: `frontend/services/dto/task-document.ts` - Create: `frontend/services/task-documents.ts` - [ ] **Step 1: Create DTO** ```typescript import type { UserData } from './user-data' export type TaskDocument = { '@id'?: string id: number task: string originalName: string fileName: string mimeType: string size: number createdAt: string uploadedBy: UserData | null } ``` - [ ] **Step 2: Add `documents` to Task DTO** In `frontend/services/dto/task.ts`, add import and field: ```typescript import type { TaskDocument } from './task-document' ``` Add to `Task` type: ```typescript documents: TaskDocument[] ``` - [ ] **Step 3: Create task-documents service** ```typescript import type { TaskDocument } from './dto/task-document' import type { HydraCollection } from '~/utils/api' import { extractHydraMembers } from '~/utils/api' import { $fetch } from 'ofetch' export function useTaskDocumentService() { const api = useApi() const config = useRuntimeConfig() const baseURL = config.public.apiBase || '/api' async function getByTask(taskId: number): Promise { const data = await api.get>('/task_documents', { task: `/api/tasks/${taskId}`, }) return extractHydraMembers(data) } async function upload(taskId: number, file: File, onProgress?: (percent: number) => void): Promise { const formData = new FormData() formData.append('file', file) formData.append('task', `/api/tasks/${taskId}`) return await $fetch(`${baseURL}/task_documents`, { method: 'POST', body: formData, credentials: 'include', // Do NOT set Content-Type — browser sets multipart boundary automatically }) } async function remove(id: number): Promise { await api.delete(`/task_documents/${id}`, {}, { toastSuccessKey: 'taskDocuments.deleted', }) } function getDownloadUrl(id: number): string { return `${baseURL}/task_documents/${id}/download` } return { getByTask, upload, remove, getDownloadUrl } } ``` - [ ] **Step 4: Commit** ```bash git add frontend/services/dto/task-document.ts frontend/services/task-documents.ts frontend/services/dto/task.ts git commit -m "feat(frontend) : add TaskDocument DTO and service" ``` --- ### Task 9: i18n translations **Files:** - Modify: `frontend/i18n/locales/fr.json` - [ ] **Step 1: Add translation keys** Add after the `"tasks"` block in `fr.json`: ```json "taskDocuments": { "title": "Documents", "dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner", "uploaded": "Document ajouté avec succès.", "deleted": "Document supprimé avec succès.", "uploadError": "Erreur lors de l'upload du document.", "confirmDeleteTitle": "Supprimer le document", "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?", "download": "Télécharger", "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo." } ``` - [ ] **Step 2: Commit** ```bash git add frontend/i18n/locales/fr.json git commit -m "feat(frontend) : add task documents i18n translations" ``` --- ### Task 10: TaskDocumentUpload component **Files:** - Create: `frontend/components/task/TaskDocumentUpload.vue` - [ ] **Step 1: Create the upload component** ```vue ``` - [ ] **Step 2: Commit** ```bash git add frontend/components/task/TaskDocumentUpload.vue git commit -m "feat(frontend) : add TaskDocumentUpload drag & drop component" ``` --- ### Task 11: TaskDocumentList component **Files:** - Create: `frontend/components/task/TaskDocumentList.vue` - [ ] **Step 1: Create the document list component** ```vue ``` - [ ] **Step 2: Commit** ```bash git add frontend/components/task/TaskDocumentList.vue git commit -m "feat(frontend) : add TaskDocumentList with thumbnails and icons" ``` --- ### Task 12: TaskDocumentPreview modal **Files:** - Create: `frontend/components/task/TaskDocumentPreview.vue` - [ ] **Step 1: Create the preview modal** ```vue