From 330b9376f62ad9ce3921385fcf64bbb0b7316d19 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 24 Mar 2026 08:49:46 +0100 Subject: [PATCH] feat(comments) : add file attachments on comments Comments can now have documents attached via multipart/form-data upload. New endpoint GET /api/documents/comment/{id} to list a comment's files. Document entity gains a comment relation with cascade remove. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/Version20260323160000.php | 30 ++++ src/Controller/CommentController.php | 146 +++++++++++++++++- src/Controller/DocumentQueryController.php | 19 +++ src/Entity/Comment.php | 13 ++ src/Entity/Document.php | 19 ++- src/State/DocumentUploadProcessor.php | 1 + .../Api/Controller/CommentControllerTest.php | 96 ++++++++++++ 7 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 migrations/Version20260323160000.php diff --git a/migrations/Version20260323160000.php b/migrations/Version20260323160000.php new file mode 100644 index 0000000..9ca30f1 --- /dev/null +++ b/migrations/Version20260323160000.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/src/Controller/CommentController.php b/src/Controller/CommentController.php index 5f5124a..ccf12a6 100644 --- a/src/Controller/CommentController.php +++ b/src/Controller/CommentController.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Comment; +use App\Entity\Document; +use App\Enum\DocumentType; use App\Repository\ProfileRepository; +use App\Service\DocumentStorageService; use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -20,6 +24,7 @@ final class CommentController extends AbstractController public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ProfileRepository $profiles, + private readonly DocumentStorageService $storageService, ) {} #[Route('', name: 'api_comments_create', methods: ['POST'])] @@ -38,16 +43,25 @@ final class CommentController extends AbstractController return $this->json(['message' => 'Profil introuvable.'], 401); } - $payload = json_decode($request->getContent(), true); - if (!is_array($payload)) { - return $this->json(['message' => 'Payload JSON invalide.'], 400); + // Parse fields from JSON or form-data + $contentType = $request->headers->get('Content-Type', ''); + $isFormData = str_contains($contentType, 'multipart/form-data') || $request->files->count() > 0 || $request->request->has('content'); + if ($isFormData) { + $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; } - $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); } @@ -75,6 +89,36 @@ final class CommentController extends AbstractController $comment->setAuthorName($authorName); $this->entityManager->persist($comment); + + // Handle file uploads + $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); + $comment->getDocuments()->add($document); + + $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); @@ -112,6 +156,76 @@ final class CommentController extends AbstractController return $this->json($this->normalize($comment)); } + #[Route('/search/list', name: 'api_comments_list', methods: ['GET'])] + public function list(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_VIEWER'); + + $qb = $this->entityManager->getRepository(Comment::class)->createQueryBuilder('c'); + + $status = $request->query->get('status'); + if ($status) { + $qb->andWhere('c.status = :status')->setParameter('status', $status); + } + + $entityType = $request->query->get('entityType'); + if ($entityType) { + $qb->andWhere('c.entityType = :entityType')->setParameter('entityType', $entityType); + } + + $entityName = $request->query->get('entityName'); + if ($entityName) { + $qb->andWhere('LOWER(c.entityName) LIKE LOWER(:entityName)')->setParameter('entityName', '%'.$entityName.'%'); + } + + // Count total before pagination + $countQb = clone $qb; + $total = (int) $countQb->select('COUNT(c.id)')->getQuery()->getSingleScalarResult(); + + // Sorting + $sortField = $request->query->get('sort', 'createdAt'); + $sortDir = strtoupper($request->query->get('direction', 'DESC')); + $allowedSortFields = ['createdAt', 'authorName', 'status']; + if (!in_array($sortField, $allowedSortFields, true)) { + $sortField = 'createdAt'; + } + if (!in_array($sortDir, ['ASC', 'DESC'], true)) { + $sortDir = 'DESC'; + } + $qb->orderBy('c.'.$sortField, $sortDir); + + // Pagination + $itemsPerPage = min((int) $request->query->get('itemsPerPage', '30'), 200); + $page = max((int) $request->query->get('page', '1'), 1); + $qb->setMaxResults($itemsPerPage)->setFirstResult(($page - 1) * $itemsPerPage); + + $comments = $qb->getQuery()->getResult(); + + return $this->json([ + 'items' => array_map(fn (Comment $c) => $this->normalize($c), $comments), + 'total' => $total, + ]); + } + + #[Route('/by-entity/{entityType}/{entityId}', name: 'api_comments_by_entity', methods: ['GET'])] + public function listByEntity(string $entityType, string $entityId, Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_VIEWER'); + + $criteria = ['entityType' => $entityType, 'entityId' => $entityId]; + + $status = $request->query->get('status'); + if ($status) { + $criteria['status'] = $status; + } + + $comments = $this->entityManager->getRepository(Comment::class) + ->findBy($criteria, ['createdAt' => 'DESC']) + ; + + return $this->json(array_map(fn (Comment $c) => $this->normalize($c), $comments)); + } + #[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])] public function unresolvedCount(): JsonResponse { @@ -126,6 +240,21 @@ final class CommentController extends AbstractController 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(), @@ -140,6 +269,7 @@ final class CommentController extends AbstractController 'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM), 'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM), 'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM), + 'documents' => $documents, ]; } } diff --git a/src/Controller/DocumentQueryController.php b/src/Controller/DocumentQueryController.php index 55824bf..d435e49 100644 --- a/src/Controller/DocumentQueryController.php +++ b/src/Controller/DocumentQueryController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\Comment; use App\Entity\Document; use App\Repository\ComposantRepository; use App\Repository\DocumentRepository; @@ -11,6 +12,7 @@ use App\Repository\MachineRepository; use App\Repository\PieceRepository; use App\Repository\ProductRepository; use App\Repository\SiteRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; @@ -25,6 +27,7 @@ class DocumentQueryController extends AbstractController private readonly ComposantRepository $composantRepository, private readonly PieceRepository $pieceRepository, private readonly ProductRepository $productRepository, + private readonly EntityManagerInterface $em, ) {} #[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])] @@ -102,6 +105,21 @@ class DocumentQueryController extends AbstractController return $this->json($this->normalizeDocuments($documents)); } + #[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])] + public function listByComment(string $id): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_VIEWER'); + + $comment = $this->em->find(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)); + } + /** * @param Document[] $documents */ @@ -121,6 +139,7 @@ class DocumentQueryController extends AbstractController 'composantId' => $document->getComposant()?->getId(), 'pieceId' => $document->getPiece()?->getId(), 'productId' => $document->getProduct()?->getId(), + 'commentId' => $document->getComment()?->getId(), 'type' => $document->getType()->value, 'createdAt' => $document->getCreatedAt()->format(DATE_ATOM), 'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM), diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php index 378c12d..8f2257d 100644 --- a/src/Entity/Comment.php +++ b/src/Entity/Comment.php @@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use App\Entity\Trait\CuidEntityTrait; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -79,10 +81,15 @@ class Comment #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')] private DateTimeImmutable $updatedAt; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])] + private Collection $documents; + public function __construct() { $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); + $this->documents = new ArrayCollection(); } public function getContent(): string @@ -204,4 +211,10 @@ class Comment return $this; } + + /** @return Collection */ + public function getDocuments(): Collection + { + return $this->documents; + } } diff --git a/src/Entity/Document.php b/src/Entity/Document.php index b8b9fc7..fb52dc6 100644 --- a/src/Entity/Document.php +++ b/src/Entity/Document.php @@ -28,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'documents')] #[ORM\HasLifecycleCallbacks] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])] -#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])] +#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])] #[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])] #[ApiResource( description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.', @@ -108,6 +108,11 @@ class Document #[Groups(['document:list'])] private ?Site $site = null; + #[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; + #[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])] private DocumentType $type = DocumentType::DOCUMENTATION; @@ -256,4 +261,16 @@ class Document return $this; } + + public function getComment(): ?Comment + { + return $this->comment; + } + + public function setComment(?Comment $comment): static + { + $this->comment = $comment; + + return $this; + } } diff --git a/src/State/DocumentUploadProcessor.php b/src/State/DocumentUploadProcessor.php index 62215db..00e8b35 100644 --- a/src/State/DocumentUploadProcessor.php +++ b/src/State/DocumentUploadProcessor.php @@ -109,6 +109,7 @@ final class DocumentUploadProcessor implements ProcessorInterface 'pieceId' => 'Piece', 'productId' => 'Product', 'siteId' => 'Site', + 'commentId' => 'Comment', ]; foreach ($relationMap as $field => $entityName) { diff --git a/tests/Api/Controller/CommentControllerTest.php b/tests/Api/Controller/CommentControllerTest.php index d86c981..5a3bd2b 100644 --- a/tests/Api/Controller/CommentControllerTest.php +++ b/tests/Api/Controller/CommentControllerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Api\Controller; use App\Tests\AbstractApiTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; /** * @internal @@ -147,4 +148,99 @@ class CommentControllerTest extends AbstractApiTestCase $this->assertResponseIsSuccessful(); $this->assertJsonContains(['count' => 2]); } + + public function testCreateCommentJsonReturnsDocumentsArray(): void + { + $machine = $this->createMachine('Machine A'); + + $client = $this->createViewerClient(); + $response = $client->request('POST', '/api/comments', [ + 'json' => [ + 'content' => 'No files', + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + $this->assertSame([], $data['documents']); + } + + public function testCreateCommentWithFile(): void + { + $machine = $this->createMachine('Machine A'); + + $client = $this->createViewerClient(); + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tmpFile, 'test file content'); + + $uploadedFile = new UploadedFile( + $tmpFile, + 'test-doc.pdf', + 'application/pdf', + null, + true, + ); + + $client->request('POST', '/api/comments', [ + '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']); + $this->assertArrayHasKey('fileUrl', $data['documents'][0]); + $this->assertArrayHasKey('downloadUrl', $data['documents'][0]); + + @unlink($tmpFile); + } + + 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 UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true); + $file2 = new 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); + } }