diff --git a/docs/superpowers/plans/2026-03-15-task-documents.md b/docs/superpowers/plans/2026-03-15-task-documents.md new file mode 100644 index 0000000..5d00581 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-task-documents.md @@ -0,0 +1,1302 @@ +# 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 +