diff --git a/migrations/Version20260924140000.php b/migrations/Version20260924140000.php new file mode 100644 index 0000000..9f71a8d --- /dev/null +++ b/migrations/Version20260924140000.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE upload ADD token VARCHAR(64) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_17BDE61F5F37A13B ON upload (token)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_17BDE61F5F37A13B ON upload'); + $this->addSql('ALTER TABLE upload DROP token'); + } +} diff --git a/src/Config/UploadToken.php b/src/Config/UploadToken.php new file mode 100644 index 0000000..3e334bd --- /dev/null +++ b/src/Config/UploadToken.php @@ -0,0 +1,30 @@ + 'Handout für die 2. Mail an Bewerber:innen', + }; + } +} diff --git a/src/Controller/Administrative/System/Document/EditController.php b/src/Controller/Administrative/System/Document/EditController.php index 2faa4a6..6dfc6f4 100644 --- a/src/Controller/Administrative/System/Document/EditController.php +++ b/src/Controller/Administrative/System/Document/EditController.php @@ -2,11 +2,14 @@ namespace App\Controller\Administrative\System\Document; +use App\Config\UploadToken; use App\Entity\Upload; use App\Htmx\HxRedirectResponse; +use App\Repository\UploadRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,6 +21,7 @@ class EditController extends AbstractController public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, + private readonly UploadRepository $uploadRepository, ) { } @@ -26,6 +30,7 @@ class EditController extends AbstractController public function index(Upload $upload, Request $request): Response { $nameBefore = $upload->getDisplayName(); + $tokenBefore = $upload->getToken(); $form = $this ->createFormBuilder($upload) @@ -35,19 +40,37 @@ class EditController extends AbstractController 'placeholder' => $upload->getOriginalFilename(), ], ]) + ->add('token', EnumType::class, [ + 'label' => 'Verwendung', + 'class' => UploadToken::class, + 'required' => false, + 'placeholder' => 'keine besondere Verwendung', + 'choice_label' => fn (UploadToken $token) => $token->label(), + 'help' => 'Eine Verwendung gehört immer zu genau einem Dokument. Wird sie hier gesetzt, verliert das bisherige Dokument sie.', + ]) ->getForm() ; $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->entityManager->flush(); - $this->addFlash('success', 'Das Dokument wurden umbenannt'); + // The column is unique, so taking a slot has to free it first - otherwise the flush + // dies on the index. Moving rather than refusing is what staff mean by assigning it: + // "this is the handout now". + $previousHolderId = null === $upload->getToken() + ? null + : $this->uploadRepository->releaseToken($upload->getToken(), $upload); - $this->logger->info('Rename document', [ + $this->entityManager->flush(); + $this->addFlash('success', 'Das Dokument wurde aktualisiert'); + + $this->logger->info('Edit document', [ 'document_id' => $upload->getId(), 'document_filename' => $upload->getOriginalFilename(), 'document_name_before' => $nameBefore, 'document_name_after' => $upload->getDisplayName(), + 'document_token_before' => $tokenBefore?->value, + 'document_token_after' => $upload->getToken()?->value, + 'document_token_taken_from' => $previousHolderId, ]); return new HxRedirectResponse($this->generateUrl('app_administrative_system_document_index')); diff --git a/src/Entity/Upload.php b/src/Entity/Upload.php index 4174d1d..ec0492e 100644 --- a/src/Entity/Upload.php +++ b/src/Entity/Upload.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Config\UploadToken; use App\Entity\Traits\BlameableEntity; use App\Entity\Traits\TimestampableEntity; use App\Model\UploadDto; @@ -53,6 +54,14 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface #[ORM\Column(length: 255, nullable: true)] private ?string $displayName = null; + /** + * Marks this document as the one a specific feature looks up - the handout a mail attaches, + * and whatever else earns a slot later. Unique, so assigning a token that another document + * holds moves it rather than duplicating it (see the document library's edit action). + */ + #[ORM\Column(length: 64, nullable: true, unique: true, enumType: UploadToken::class)] + private ?UploadToken $token = null; + #[ORM\Column] private ?int $size = null; @@ -163,6 +172,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface return static::STATUS_REJECTED === $this->getStatus(); } + public function getToken(): ?UploadToken + { + return $this->token; + } + + public function setToken(?UploadToken $token): static + { + $this->token = $token; + + return $this; + } + public function getFilename(): ?string { return $this->filename; diff --git a/src/Repository/UploadRepository.php b/src/Repository/UploadRepository.php index 3af08a7..ad91bd9 100644 --- a/src/Repository/UploadRepository.php +++ b/src/Repository/UploadRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Config\UploadToken; use App\Entity\Upload; use App\Model\DocumentFilterDto; use App\Repository\Traits\QueryHelperTrait; @@ -231,4 +232,38 @@ class UploadRepository extends ServiceEntityRepository ->getSingleScalarResult() ; } + + public function findByToken(UploadToken $token): ?Upload + { + return $this->findOneBy(['token' => $token]); + } + + /** + * Frees a token slot, so the document taking it can be flushed without tripping the unique + * index. The update runs immediately rather than through the unit of work on purpose: both + * rows are Uploads, and nothing guarantees Doctrine writes the release before it writes the + * row that takes the token. + * + * Returns the id of the document that lost the slot, for the log. + */ + public function releaseToken(UploadToken $token, Upload $takenBy): ?int + { + $holder = $this->findByToken($token); + + if (null === $holder || $holder === $takenBy) { + return null; + } + + $this + ->getEntityManager() + ->createQuery('UPDATE App\Entity\Upload u SET u.token = NULL WHERE u.id = :id') + ->setParameter('id', $holder->getId()) + ->execute() + ; + + // Keeps the loaded entity in step with the row that was just written. + $holder->setToken(null); + + return $holder->getId(); + } } diff --git a/templates/administrative/system/document/index.html.twig b/templates/administrative/system/document/index.html.twig index 8592cb0..d3e0556 100644 --- a/templates/administrative/system/document/index.html.twig +++ b/templates/administrative/system/document/index.html.twig @@ -14,6 +14,9 @@