# Comment Document Attachments — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow users to attach one or more documents when creating a comment, via a single multipart/form-data request. **Architecture:** Add a `comment` ManyToOne on Document entity (same pattern as machine/site/etc.), modify `CommentController::create()` to accept multipart/form-data with files + text fields, store files via existing `DocumentStorageService`, and update the frontend `CommentSection.vue` to include a file picker. **Tech Stack:** Symfony 8, Doctrine, API Platform, Vue 3 Composition API, TypeScript, TailwindCSS/DaisyUI --- ### Task 1: Migration — add `comment_id` FK on `documents` **Files:** - Create: `migrations/Version20260323160000.php` - [ ] **Step 1: Create the migration** ```php addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END $$"); $this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END $$"); $this->addSql("CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)"); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment'); $this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id'); $this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id'); } } ``` - [ ] **Step 2: Run the migration** Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction` Expected: Migration executes successfully. - [ ] **Step 3: Update test schema** Run: `make test-setup` - [ ] **Step 4: Commit** ```bash git add migrations/Version20260323160000.php git commit -m "feat(documents) : add comment_id FK on documents table" ``` --- ### Task 2: Entity updates — Document.comment + Comment.documents **Files:** - Modify: `src/Entity/Document.php` - Modify: `src/Entity/Comment.php` - [ ] **Step 1: Add `comment` ManyToOne on Document entity** In `src/Entity/Document.php`, add after the `$site` property (around line 109): ```php #[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')] #[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['document:list'])] private ?Comment $comment = null; ``` And add getter/setter: ```php public function getComment(): ?Comment { return $this->comment; } public function setComment(?Comment $comment): static { $this->comment = $comment; return $this; } ``` - [ ] **Step 2: Add `documents` OneToMany on Comment entity** In `src/Entity/Comment.php`, add the import: ```php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; ``` Add property after `$updatedAt`: ```php /** @var Collection */ #[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])] private Collection $documents; ``` Initialize in constructor: ```php public function __construct() { $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); $this->documents = new ArrayCollection(); } ``` Add getter: ```php /** @return Collection */ public function getDocuments(): Collection { return $this->documents; } ``` - [ ] **Step 3: Run php-cs-fixer** Run: `make php-cs-fixer-allow-risky` - [ ] **Step 4: Run tests to check nothing broke** Run: `make test` Expected: All existing tests pass. - [ ] **Step 5: Commit** ```bash git add src/Entity/Document.php src/Entity/Comment.php git commit -m "feat(documents) : add Comment-Document relationship (ManyToOne/OneToMany)" ``` --- ### Task 3: Update CommentController to accept multipart/form-data with files **Files:** - Modify: `src/Controller/CommentController.php` - [ ] **Step 1: Add DocumentStorageService dependency and update create() method** Update constructor to inject `DocumentStorageService`: ```php use App\Entity\Document; use App\Enum\DocumentType; use App\Service\DocumentStorageService; use Symfony\Component\HttpFoundation\File\UploadedFile; ``` ```php public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ProfileRepository $profiles, private readonly DocumentStorageService $storageService, ) {} ``` Replace the `create()` method body to handle both JSON and multipart: ```php #[Route('', name: 'api_comments_create', methods: ['POST'])] public function create(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_VIEWER'); $session = $request->getSession(); $profileId = $session->get('profileId'); if (!$profileId) { return $this->json(['message' => 'Aucun profil actif.'], 401); } $profile = $this->profiles->find($profileId); if (!$profile) { return $this->json(['message' => 'Profil introuvable.'], 401); } // Parse fields from JSON or form-data $contentType = $request->headers->get('Content-Type', ''); if (str_contains($contentType, 'multipart/form-data')) { $content = trim((string) $request->request->get('content', '')); $entityType = trim((string) $request->request->get('entityType', '')); $entityId = trim((string) $request->request->get('entityId', '')); $entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null; } else { $payload = json_decode($request->getContent(), true); if (!is_array($payload)) { return $this->json(['message' => 'Payload JSON invalide.'], 400); } $content = trim((string) ($payload['content'] ?? '')); $entityType = trim((string) ($payload['entityType'] ?? '')); $entityId = trim((string) ($payload['entityId'] ?? '')); $entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null; } if ('' === $content) { return $this->json(['message' => 'Le contenu est requis.'], 400); } $allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton']; if (!in_array($entityType, $allowedTypes, true)) { return $this->json(['message' => 'Type d\'entité invalide.'], 400); } if ('' === $entityId) { return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400); } $authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); if ('' === $authorName) { $authorName = $profile->getEmail() ?? 'Inconnu'; } $comment = new Comment(); $comment->setContent($content); $comment->setEntityType($entityType); $comment->setEntityId($entityId); $comment->setEntityName($entityName); $comment->setAuthorId($profileId); $comment->setAuthorName($authorName); $this->entityManager->persist($comment); // Handle file uploads /** @var UploadedFile[] $files */ $files = $request->files->all('files'); foreach ($files as $file) { if (!$file instanceof UploadedFile || !$file->isValid()) { continue; } $document = new Document(); $documentId = 'cl'.bin2hex(random_bytes(12)); $document->setId($documentId); $document->setName($file->getClientOriginalName()); $document->setFilename($file->getClientOriginalName()); $document->setMimeType($file->getMimeType() ?: 'application/octet-stream'); $document->setSize((int) $file->getSize()); $document->setType(DocumentType::DOCUMENTATION); $document->setComment($comment); $extension = $this->storageService->extensionFromFilename($file->getClientOriginalName()); $relativePath = $this->storageService->storeFromPath( $file->getPathname(), $documentId, $extension, ); $document->setPath($relativePath); $this->entityManager->persist($document); } $this->entityManager->flush(); return $this->json($this->normalize($comment), 201); } ``` - [ ] **Step 2: Update normalize() to include documents** ```php private function normalize(Comment $comment): array { $documents = []; foreach ($comment->getDocuments() as $document) { $documents[] = [ 'id' => $document->getId(), 'name' => $document->getName(), 'filename' => $document->getFilename(), 'mimeType' => $document->getMimeType(), 'size' => $document->getSize(), 'type' => $document->getType()->value, 'fileUrl' => '/api/documents/'.$document->getId().'/file', 'downloadUrl' => '/api/documents/'.$document->getId().'/download', 'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM), ]; } return [ 'id' => $comment->getId(), 'content' => $comment->getContent(), 'entityType' => $comment->getEntityType(), 'entityId' => $comment->getEntityId(), 'entityName' => $comment->getEntityName(), 'authorId' => $comment->getAuthorId(), 'authorName' => $comment->getAuthorName(), 'status' => $comment->getStatus(), 'resolvedById' => $comment->getResolvedById(), 'resolvedByName' => $comment->getResolvedByName(), 'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM), 'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM), 'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM), 'documents' => $documents, ]; } ``` - [ ] **Step 3: Run php-cs-fixer** Run: `make php-cs-fixer-allow-risky` - [ ] **Step 4: Run tests** Run: `make test` Expected: All existing tests still pass (they use JSON, not multipart). - [ ] **Step 5: Commit** ```bash git add src/Controller/CommentController.php git commit -m "feat(comments) : accept multipart/form-data with file uploads on create" ``` --- ### Task 4: Update DocumentUploadProcessor and DocumentQueryController **Files:** - Modify: `src/State/DocumentUploadProcessor.php` - Modify: `src/Controller/DocumentQueryController.php` - [ ] **Step 1: Add `commentId` to DocumentUploadProcessor relation map** In `src/State/DocumentUploadProcessor.php`, update `$relationMap` in `setRelationsFromRequest()`: ```php $relationMap = [ 'machineId' => 'Machine', 'composantId' => 'Composant', 'pieceId' => 'Piece', 'productId' => 'Product', 'siteId' => 'Site', 'commentId' => 'Comment', ]; ``` - [ ] **Step 2: Add comment route to DocumentQueryController** Add `CommentRepository` import and inject it, then add the route: ```php use App\Repository\CommentRepository; ``` Add to constructor: ```php private readonly CommentRepository $commentRepository, ``` Wait — `Comment` has no repository. Use the EntityManager instead. Add the route method: ```php #[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])] public function listByComment(string $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_VIEWER'); $comment = $this->getEntityManager()->getRepository(\App\Entity\Comment::class)->find($id); if (!$comment) { return $this->json(['success' => false, 'error' => 'Comment not found.'], 404); } $documents = $this->documentRepository->findBy(['comment' => $comment]); return $this->json($this->normalizeDocuments($documents)); } ``` Actually, the controller doesn't have `getEntityManager()`. Use `DocumentRepository` directly: ```php #[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])] public function listByComment(string $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_VIEWER'); $documents = $this->documentRepository->findBy(['comment' => $id]); return $this->json($this->normalizeDocuments($documents)); } ``` Wait — `findBy(['comment' => $id])` won't work with a string ID directly on a relation. Let me use the pattern from the existing code and add the Comment entity lookup. The simplest approach: inject `EntityManagerInterface`. Actually, looking at the existing pattern more carefully, the other methods fetch the entity first and pass the object. We can use the documentRepository's entity manager. Let's just follow the exact same pattern and add a dependency. But actually, let's keep it simple — the documents table has `comment_id` column, so we can use a custom query. The simplest: just inject EntityManagerInterface. ```php use Doctrine\ORM\EntityManagerInterface; ``` Add to constructor: `private readonly EntityManagerInterface $em,` ```php #[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])] public function listByComment(string $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_VIEWER'); $comment = $this->em->find(\App\Entity\Comment::class, $id); if (!$comment) { return $this->json(['success' => false, 'error' => 'Comment not found.'], 404); } $documents = $this->documentRepository->findBy(['comment' => $comment]); return $this->json($this->normalizeDocuments($documents)); } ``` - [ ] **Step 3: Update normalizeDocuments to include commentId** Add to the normalizeDocuments return array: ```php 'commentId' => $document->getComment()?->getId(), ``` - [ ] **Step 4: Run php-cs-fixer + tests** Run: `make php-cs-fixer-allow-risky && make test` - [ ] **Step 5: Commit** ```bash git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php git commit -m "feat(documents) : add comment support in upload processor and query controller" ``` --- ### Task 5: Backend tests — comment with documents **Files:** - Modify: `tests/Api/Controller/CommentControllerTest.php` - [ ] **Step 1: Add test for creating comment with files** ```php public function testCreateCommentWithFiles(): void { $machine = $this->createMachine('Machine A'); $client = $this->createViewerClient(); // Create a temporary file for upload $tmpFile = tempnam(sys_get_temp_dir(), 'test_'); file_put_contents($tmpFile, 'test file content'); $uploadedFile = new \Symfony\Component\HttpFoundation\File\UploadedFile( $tmpFile, 'test-doc.pdf', 'application/pdf', null, true, ); $client->request('POST', '/api/comments', [ 'headers' => ['Content-Type' => 'multipart/form-data'], 'extra' => [ 'parameters' => [ 'content' => 'Comment with file', 'entityType' => 'machine', 'entityId' => $machine->getId(), 'entityName' => 'Machine A', ], 'files' => [ 'files' => [$uploadedFile], ], ], ]); $this->assertResponseStatusCodeSame(201); $data = json_decode($client->getResponse()->getContent(), true); $this->assertSame('Comment with file', $data['content']); $this->assertCount(1, $data['documents']); $this->assertSame('test-doc.pdf', $data['documents'][0]['filename']); @unlink($tmpFile); } ``` - [ ] **Step 2: Add test for creating comment with multiple files** ```php public function testCreateCommentWithMultipleFiles(): void { $machine = $this->createMachine('Machine A'); $client = $this->createViewerClient(); $tmpFile1 = tempnam(sys_get_temp_dir(), 'test_'); file_put_contents($tmpFile1, 'content 1'); $tmpFile2 = tempnam(sys_get_temp_dir(), 'test_'); file_put_contents($tmpFile2, 'content 2'); $file1 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true); $file2 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true); $client->request('POST', '/api/comments', [ 'extra' => [ 'parameters' => [ 'content' => 'Multiple files', 'entityType' => 'machine', 'entityId' => $machine->getId(), ], 'files' => [ 'files' => [$file1, $file2], ], ], ]); $this->assertResponseStatusCodeSame(201); $data = json_decode($client->getResponse()->getContent(), true); $this->assertCount(2, $data['documents']); @unlink($tmpFile1); @unlink($tmpFile2); } ``` - [ ] **Step 3: Add test that existing JSON create still works and returns empty documents array** ```php public function testCreateCommentJsonStillReturnsDocuments(): void { $machine = $this->createMachine('Machine A'); $client = $this->createViewerClient(); $client->request('POST', '/api/comments', [ 'json' => [ 'content' => 'No files', 'entityType' => 'machine', 'entityId' => $machine->getId(), ], ]); $this->assertResponseStatusCodeSame(201); $data = json_decode($client->getResponse()->getContent(), true); $this->assertSame([], $data['documents']); } ``` - [ ] **Step 4: Run tests** Run: `make test` Expected: All tests pass. - [ ] **Step 5: Commit** ```bash git add tests/Api/Controller/CommentControllerTest.php git commit -m "test(comments) : add tests for comment creation with file attachments" ``` --- ### Task 6: Frontend — update useComments composable **Files:** - Modify: `frontend/app/composables/useComments.ts` - [ ] **Step 1: Add document type to Comment interface** ```typescript export interface CommentDocument { id: string name: string filename: string mimeType: string size: number type: string fileUrl: string downloadUrl: string createdAt: string } export interface Comment { id: string content: string entityType: string entityId: string entityName?: string | null authorId: string authorName: string status: 'open' | 'resolved' resolvedById?: string | null resolvedByName?: string | null resolvedAt?: string | null createdAt: string updatedAt: string documents: CommentDocument[] } ``` - [ ] **Step 2: Update createComment to accept files and use FormData** Add `postFormData` to the destructured `useApi()` call: ```typescript const { get, post, patch, postFormData, delete: del } = useApi() ``` Update `createComment`: ```typescript const createComment = async ( entityType: string, entityId: string, content: string, entityName?: string, files?: File[], ): Promise => { loading.value = true try { let result if (files && files.length > 0) { const formData = new FormData() formData.append('content', content) formData.append('entityType', entityType) formData.append('entityId', entityId) if (entityName) formData.append('entityName', entityName) for (const file of files) { formData.append('files[]', file) } result = await postFormData('/comments', formData) } else { const payload: Record = { entityType, entityId, content } if (entityName) payload.entityName = entityName result = await post('/comments', payload) } if (result.success) { showSuccess('Commentaire ajouté') return { success: true, data: result.data as Comment } } if (result.error) showError(result.error) return { success: false, error: result.error } } catch (error) { const err = error as Error showError('Impossible d\'ajouter le commentaire') return { success: false, error: err.message } } finally { loading.value = false } } ``` - [ ] **Step 3: Run lint + typecheck** Run: `cd frontend && npm run lint:fix && npx nuxi typecheck` - [ ] **Step 4: Commit (in frontend submodule)** ```bash cd frontend git add app/composables/useComments.ts git commit -m "feat(comments) : support file attachments in createComment" ``` --- ### Task 7: Frontend — update CommentSection.vue **Files:** - Modify: `frontend/app/components/CommentSection.vue` - [ ] **Step 1: Add file input and file list display to the template** Replace the form section (lines 22-40) with: ```vue