feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup)
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Domain\Entity;
|
||||||
|
|
||||||
|
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\Post;
|
||||||
|
use App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor;
|
||||||
|
use App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener;
|
||||||
|
use App\Shared\Domain\Contract\UserInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||||
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
processor: ReportDocumentProcessor::class,
|
||||||
|
deserialize: false,
|
||||||
|
),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
|
|
||||||
|
interface ReportDocumentRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?ReportDocument;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<ReportDocument, ReportDocument>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Controller;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
final class ReportDocumentDownloadController
|
||||||
|
{
|
||||||
|
private const INLINE_MIME_TYPES = [
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReportDocumentRepositoryInterface $repository,
|
||||||
|
private readonly string $uploadDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function __invoke(int $id): Response
|
||||||
|
{
|
||||||
|
$document = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
|
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ReportDocument>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\EventListener;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
|
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final readonly class ReportDocumentListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $uploadDir,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function preRemove(ReportDocument $document, PreRemoveEventArgs $args): void
|
||||||
|
{
|
||||||
|
$fileName = $document->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user