feat(comments) : add comment entity, controller and migration

Create Comment entity with API Platform annotations (GET, PATCH, DELETE).
Add CommentController with POST (create), PATCH (resolve) and GET
(unresolved count) endpoints. Add migration for comments table and
piece reference unique index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-02 14:06:25 +01:00
parent 9661fd5d91
commit 7bbb693924
3 changed files with 431 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Comment;
use App\Repository\ProfileRepository;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/comments')]
final class CommentController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
) {}
#[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);
}
$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);
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
#[Route('/{id}/resolve', name: 'api_comments_resolve', methods: ['PATCH'])]
public function resolve(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$comment = $this->entityManager->getRepository(Comment::class)->find($id);
if (!$comment) {
return $this->json(['message' => 'Commentaire introuvable.'], 404);
}
$session = $request->getSession();
$profileId = $session->get('profileId');
$profile = $profileId ? $this->profiles->find($profileId) : null;
$resolverName = 'Inconnu';
if ($profile) {
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $resolverName) {
$resolverName = $profile->getEmail() ?? 'Inconnu';
}
}
$comment->setStatus('resolved');
$comment->setResolvedById($profileId);
$comment->setResolvedByName($resolverName);
$comment->setResolvedAt(new DateTimeImmutable());
$this->entityManager->flush();
return $this->json($this->normalize($comment));
}
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
public function unresolvedCount(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$count = $this->entityManager->getRepository(Comment::class)
->count(['status' => 'open'])
;
return $this->json(['count' => $count]);
}
private function normalize(Comment $comment): array
{
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),
];
}
}

235
src/Entity/Comment.php Normal file
View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'comments')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_comment_entity_status')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt'])]
#[ApiResource(
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
order: ['createdAt' => 'DESC'],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Comment
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::TEXT)]
private string $content;
#[ORM\Column(type: Types::STRING, length: 50, name: 'entity_type')]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36, name: 'entity_id')]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'entity_name')]
private ?string $entityName = null;
#[ORM\Column(type: Types::STRING, length: 36, name: 'author_id')]
private string $authorId;
#[ORM\Column(type: Types::STRING, length: 255, name: 'author_name')]
private string $authorName;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $status = 'open';
#[ORM\Column(type: Types::STRING, length: 36, nullable: true, name: 'resolved_by_id')]
private ?string $resolvedById = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'resolved_by_name')]
private ?string $resolvedByName = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, name: 'resolved_at')]
private ?DateTimeImmutable $resolvedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function setEntityType(string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function setEntityId(string $entityId): static
{
$this->entityId = $entityId;
return $this;
}
public function getEntityName(): ?string
{
return $this->entityName;
}
public function setEntityName(?string $entityName): static
{
$this->entityName = $entityName;
return $this;
}
public function getAuthorId(): string
{
return $this->authorId;
}
public function setAuthorId(string $authorId): static
{
$this->authorId = $authorId;
return $this;
}
public function getAuthorName(): string
{
return $this->authorName;
}
public function setAuthorName(string $authorName): static
{
$this->authorName = $authorName;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getResolvedById(): ?string
{
return $this->resolvedById;
}
public function setResolvedById(?string $resolvedById): static
{
$this->resolvedById = $resolvedById;
return $this;
}
public function getResolvedByName(): ?string
{
return $this->resolvedByName;
}
public function setResolvedByName(?string $resolvedByName): static
{
$this->resolvedByName = $resolvedByName;
return $this;
}
public function getResolvedAt(): ?DateTimeImmutable
{
return $this->resolvedAt;
}
public function setResolvedAt(?DateTimeImmutable $resolvedAt): static
{
$this->resolvedAt = $resolvedAt;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}