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)); + } +}