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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user