Files
Inventory/src/State/DocumentUploadProcessor.php
Matthieu d89c97f0a0 feat(documents) : filesystem storage, server-side pagination and PDF compression
- Add DocumentStorageService for file-based storage (replaces Base64 in DB)
- Add DocumentServeController with /file and /download endpoints
- Add DocumentUploadProcessor using FormData + filesystem storage
- Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses
- Add DocumentFileCleanupListener for automatic file deletion
- Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe)
- Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity
- Add PdfCompressorService + refactor CompressPdfCommand for batch processing
- Fix TypeMachine PUT: deserialize=false + validate=false to prevent
  UniqueEntity false positive and writableLink collection interference
- Update CHANGELOG for v1.8.0
- Update frontend submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:18:55 +01:00

129 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Document;
use App\Service\DocumentStorageService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final class DocumentUploadProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $decorated,
private readonly RequestStack $requestStack,
private readonly DocumentStorageService $storageService,
private readonly EntityManagerInterface $em,
) {}
/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
$contentType = $request->headers->get('Content-Type', '');
// Multipart/form-data → file upload
if (str_contains($contentType, 'multipart/form-data')) {
return $this->handleMultipartUpload($operation, $uriVariables, $context, $request);
}
// Fallback to default processor for legacy JSON/Base64 requests
if (!$data instanceof Document) {
return $this->decorated->process($data, $operation, $uriVariables, $context);
}
return $this->decorated->process($data, $operation, $uriVariables, $context);
}
private function handleMultipartUpload(
Operation $operation,
array $uriVariables,
array $context,
Request $request,
): Document {
/** @var null|UploadedFile $file */
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('A valid file is required in the "file" field.');
}
$document = new Document();
// Metadata from form fields
$name = $request->request->get('name', $file->getClientOriginalName());
$filename = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: $request->request->get('mimeType', 'application/octet-stream');
$size = $file->getSize();
$document->setName($name);
$document->setFilename($filename);
$document->setMimeType($mimeType);
$document->setSize((int) $size);
// Handle entity relations from form fields
$this->setRelationsFromRequest($document, $request);
// Generate CUID early so we can use it for the filename on disk
$documentId = 'cl'.bin2hex(random_bytes(12));
$document->setId($documentId);
// Store file on disk
$extension = $this->storageService->extensionFromFilename($filename);
$relativePath = $this->storageService->storeFromPath(
$file->getPathname(),
$documentId,
$extension,
);
$document->setPath($relativePath);
// Persist via decorated processor (triggers prePersist for timestamps)
return $this->decorated->process($document, $operation, $uriVariables, $context);
}
private function setRelationsFromRequest(Document $document, Request $request): void
{
$relationMap = [
'machineId' => 'Machine',
'composantId' => 'Composant',
'pieceId' => 'Piece',
'productId' => 'Product',
'siteId' => 'Site',
];
foreach ($relationMap as $field => $entityName) {
$value = $request->request->get($field);
if (!$value) {
continue;
}
// Accept both raw ID and IRI format
$id = $value;
if (str_contains($value, '/')) {
$parts = explode('/', rtrim($value, '/'));
$id = end($parts);
}
$entityClass = 'App\Entity\\'.$entityName;
$entity = $this->em->getReference($entityClass, $id);
$setter = 'set'.$entityName;
$document->{$setter}($entity);
}
}
}