From b9538454a964947f5caff1c1ff40aae3abaddace Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 22 Jun 2026 11:51:53 +0200 Subject: [PATCH] feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup) --- .../Domain/Entity/ReportDocument.php | 166 ++++++++++++++++++ .../ReportDocumentRepositoryInterface.php | 12 ++ .../State/ReportDocumentProcessor.php | 152 ++++++++++++++++ .../ReportDocumentDownloadController.php | 56 ++++++ .../DoctrineReportDocumentRepository.php | 26 +++ .../EventListener/ReportDocumentListener.php | 32 ++++ 6 files changed, 444 insertions(+) create mode 100644 src/Module/Directory/Domain/Entity/ReportDocument.php create mode 100644 src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php create mode 100644 src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php create mode 100644 src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php create mode 100644 src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php diff --git a/src/Module/Directory/Domain/Entity/ReportDocument.php b/src/Module/Directory/Domain/Entity/ReportDocument.php new file mode 100644 index 0000000..4c4a8b5 --- /dev/null +++ b/src/Module/Directory/Domain/Entity/ReportDocument.php @@ -0,0 +1,166 @@ + ['report_document:read']], + denormalizationContext: ['groups' => ['report_document:write']], + order: ['id' => 'DESC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['commercialReport' => 'exact'])] +#[ORM\Entity] +#[ORM\Table(name: 'report_document')] +#[ORM\EntityListeners([ReportDocumentListener::class])] +class ReportDocument +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: CommercialReport::class, inversedBy: 'documents')] + #[ORM\JoinColumn(name: 'commercial_report_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[Groups(['report_document:read', 'report_document:write'])] + private ?CommercialReport $commercialReport = null; + + #[ORM\Column(length: 255)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $originalName = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $fileName = null; + + #[ORM\Column(length: 100)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $mimeType = null; + + #[ORM\Column] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?int $size = null; + + #[ORM\Column(type: 'datetime_immutable')] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'uploaded_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?UserInterface $uploadedBy = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getCommercialReport(): ?CommercialReport + { + return $this->commercialReport; + } + + public function setCommercialReport(?CommercialReport $commercialReport): static + { + $this->commercialReport = $commercialReport; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function setFileName(?string $fileName): static + { + $this->fileName = $fileName; + + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(int $size): static + { + $this->size = $size; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUploadedBy(): ?UserInterface + { + return $this->uploadedBy; + } + + public function setUploadedBy(?UserInterface $uploadedBy): static + { + $this->uploadedBy = $uploadedBy; + + return $this; + } +} diff --git a/src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php b/src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php new file mode 100644 index 0000000..841936f --- /dev/null +++ b/src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +final readonly class ReportDocumentProcessor implements ProcessorInterface +{ + private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + + private const ALLOWED_MIME_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', 'text/csv', + 'application/zip', 'application/x-rar-compressed', 'application/gzip', + 'application/json', 'application/xml', 'text/xml', + ]; + + private const MIME_TO_EXTENSION = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'text/plain' => 'txt', + 'text/csv' => 'csv', + 'application/zip' => 'zip', + 'application/x-rar-compressed' => 'rar', + 'application/gzip' => 'gz', + 'application/json' => 'json', + 'application/xml' => 'xml', + 'text/xml' => 'xml', + ]; + + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private RequestStack $requestStack, + private string $uploadDir, + ) {} + + /** + * @param ReportDocument $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument + { + if (!$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Creating report documents requires admin privileges.'); + } + + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new BadRequestHttpException('No request available.'); + } + + $document = $this->createUpload($request); + $document->setCreatedAt(new DateTimeImmutable()); + $document->setUploadedBy($this->security->getUser()); + + $this->entityManager->persist($document); + $this->entityManager->flush(); + + return $document; + } + + private function createUpload(Request $request): ReportDocument + { + $file = $request->files->get('file'); + + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('No valid file uploaded.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('File size exceeds 50 MB limit.'); + } + + $report = $this->resolveReport((string) $request->request->get('commercialReport', '')); + + $originalName = $file->getClientOriginalName(); + $mimeType = $file->getMimeType() ?: 'application/octet-stream'; + $fileSize = $file->getSize(); + + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType)); + } + + $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; + $fileName = Uuid::v4()->toRfc4122().'.'.$extension; + + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0o775, true); + } + + $file->move($this->uploadDir, $fileName); + + $document = new ReportDocument(); + $document->setCommercialReport($report); + $document->setOriginalName($originalName); + $document->setFileName($fileName); + $document->setMimeType($mimeType); + $document->setSize($fileSize); + + return $document; + } + + private function resolveReport(string $iri): CommercialReport + { + if ('' === $iri) { + throw new BadRequestHttpException('A commercialReport IRI is required.'); + } + + $report = $this->entityManager->getRepository(CommercialReport::class)->find((int) basename($iri)); + + if (null === $report) { + throw new BadRequestHttpException('Commercial report not found.'); + } + + return $report; + } +} diff --git a/src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php b/src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php new file mode 100644 index 0000000..f31e792 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php @@ -0,0 +1,56 @@ +repository->findById($id); + + if (null === $document || null === $document->getFileName()) { + throw new NotFoundHttpException('Document not found.'); + } + + $filePath = $this->uploadDir.'/'.$document->getFileName(); + + if (!is_file($filePath)) { + throw new NotFoundHttpException('File missing on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $mimeType = (string) $document->getMimeType(); + + $disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true) + ? ResponseHeaderBag::DISPOSITION_INLINE + : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + $response->setContentDisposition($disposition, (string) $document->getOriginalName()); + $response->headers->set('Content-Type', $mimeType); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } +} diff --git a/src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php b/src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php new file mode 100644 index 0000000..20cd0c5 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php @@ -0,0 +1,26 @@ + + */ +final class DoctrineReportDocumentRepository extends ServiceEntityRepository implements ReportDocumentRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReportDocument::class); + } + + public function findById(int $id): ?ReportDocument + { + return $this->find($id); + } +} diff --git a/src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php b/src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php new file mode 100644 index 0000000..583db11 --- /dev/null +++ b/src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php @@ -0,0 +1,32 @@ +getFileName(); + + if (null === $fileName) { + return; + } + + $path = $this->uploadDir.'/'.$fileName; + + if (is_file($path) && !@unlink($path)) { + $this->logger->warning('Failed to delete report document file', ['path' => $path]); + } + } +}