feat: mark a library document for a specific purpose

This commit is contained in:
fromme committed 2026-09-24 16:35:24 +02:00
1 parent cb2d64f2cd
commit 19364df2db
8 files changed
+249 -6

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Config;
/**
* The slots in the staff-wide document library (Upload::TYPE_DOCUMENT) that code looks a file up
* by, rather than by id or name.
*
* A fixed list and not free text on purpose: something in the application has to consume a slot
* for it to mean anything, so inventing one without a release would only produce a token nothing
* reads - and a typo would look exactly the same as a document nobody has assigned yet, while
* silently dropping an attachment.
*
* A case's value is what ends up in upload.token, so renaming one orphans the assignment and the
* mail quietly loses its attachment. The column is unique: one document per slot.
*/
enum UploadToken: string
{
case APPLICATION_FORM_HANDOUT = 'application_form_handout';
/**
* What the document library offers in its dropdown.
*/
public function label(): string
{
return match ($this) {
self::APPLICATION_FORM_HANDOUT => 'Handout für die 2. Mail an Bewerber:innen',
};
}
}
@@ -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'));
+21
View File
@@ -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;
+35
View File
@@ -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();
}
}