feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s

Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.

## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.

## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).

`make test` vert (701 tests), `php-cs-fixer` propre.

## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).

Ticket ERP-154.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
2026-06-15 15:25:32 +00:00
parent 9f4f45f761
commit 1e783bd753
12 changed files with 840 additions and 0 deletions
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Infrastructure\Upload\FileUploader;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Throwable;
/**
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
*
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
* en JSON-LD (avec son @id / IRI).
*
* Mapping des erreurs :
* - fichier absent -> 422 ;
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
*
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
*
* @implements ProcessorInterface<mixed, mixed>
*/
final class UploadedDocumentProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly FileUploader $fileUploader,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (!$file instanceof UploadedFile) {
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
}
try {
$document = $this->fileUploader->upload($file);
} catch (FileUploadException $e) {
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
// message metier explicite porte par l'exception.
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
}
$user = $this->security->getUser();
if ($user instanceof UserInterface) {
$document->setCreatedBy($user);
}
try {
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
} catch (Throwable $e) {
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
// uploaded_document correspondante, puis on relaie l'erreur.
$this->fileUploader->remove($document);
throw $e;
}
}
}