From 7bbb693924b3b1ffcf9211755f54b09d1ec8dcb5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 2 Mar 2026 14:06:25 +0100 Subject: [PATCH] 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 --- migrations/Version20260302103003.php | 51 ++++++ src/Controller/CommentController.php | 145 +++++++++++++++++ src/Entity/Comment.php | 235 +++++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 migrations/Version20260302103003.php create mode 100644 src/Controller/CommentController.php create mode 100644 src/Entity/Comment.php diff --git a/migrations/Version20260302103003.php b/migrations/Version20260302103003.php new file mode 100644 index 0000000..09cb327 --- /dev/null +++ b/migrations/Version20260302103003.php @@ -0,0 +1,51 @@ +addSql('CREATE TABLE IF NOT EXISTS comments (id VARCHAR(36) NOT NULL, content TEXT NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(36) NOT NULL, entity_name VARCHAR(255) DEFAULT NULL, author_id VARCHAR(36) NOT NULL, author_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, resolved_by_id VARCHAR(36) DEFAULT NULL, resolved_by_name VARCHAR(255) DEFAULT NULL, resolved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_comment_entity_status ON comments (entity_type, entity_id, status)'); + $this->addSql('COMMENT ON COLUMN comments.resolved_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\''); + + // Piece: remove unique on name + $this->addSql('DROP INDEX IF EXISTS uniq_b92d74725e237e06'); + + // Deduplicate piece references before adding unique constraint + $this->addSql(" + UPDATE pieces p + SET reference = p.reference || '-' || LEFT(p.id, 6) + FROM ( + SELECT id, reference, + ROW_NUMBER() OVER (PARTITION BY reference ORDER BY createdat) AS rn + FROM pieces + WHERE reference IS NOT NULL AND reference != '' + ) dup + WHERE p.id = dup.id AND dup.rn > 1 + "); + + $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_pieces_reference ON pieces (reference)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS comments'); + $this->addSql('DROP INDEX IF EXISTS uniq_pieces_reference'); + $this->addSql('CREATE UNIQUE INDEX uniq_b92d74725e237e06 ON pieces (name)'); + } +} diff --git a/src/Controller/CommentController.php b/src/Controller/CommentController.php new file mode 100644 index 0000000..5f5124a --- /dev/null +++ b/src/Controller/CommentController.php @@ -0,0 +1,145 @@ +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), + ]; + } +} diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php new file mode 100644 index 0000000..f2526cf --- /dev/null +++ b/src/Entity/Comment.php @@ -0,0 +1,235 @@ + '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)); + } +}