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) <noreply@anthropic.com>
This commit is contained in:
30
migrations/Version20260323160000.php
Normal file
30
migrations/Version20260323160000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260323160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add comment_id FK on documents table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<int, Document> */
|
||||
#[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<int, Document> */
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ final class DocumentUploadProcessor implements ProcessorInterface
|
||||
'pieceId' => 'Piece',
|
||||
'productId' => 'Product',
|
||||
'siteId' => 'Site',
|
||||
'commentId' => 'Comment',
|
||||
];
|
||||
|
||||
foreach ($relationMap as $field => $entityName) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user