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

+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* A slot marker on the document library, so a feature can look a file up by what it is for
* instead of by id (App\Config\UploadToken). Unique: one document per slot.
*
* Reduced to these two statements by hand - the generated diff also wanted to drop another
* branch's upload columns and restate the long-standing datetime drift on this table.
*/
final class Version20260924140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the token slot marker to upload';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+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();
}
}
@@ -14,6 +14,9 @@
<th>
{{ knp_pagination_sortable(pagination, 'Dateiname', 'upload.originalFilename') }}
</th>
<th>
Verwendung
</th>
<th>
Dateigröße
</th>
@@ -32,6 +35,11 @@
<td>
{{ document.displayName ? document.displayName : document.originalFilename }}
</td>
<td>
{# A slot belongs to exactly one document, so this is what tells staff
which file a feature actually picks up. #}
{{ document.token ? document.token.label() : '-' }}
</td>
<td>
{{ document.size|file_size }}
</td>
@@ -71,7 +79,7 @@
</tr>
{% else %}
<tr>
<td colspan="5">
<td colspan="6">
Keine Daten...
</td>
</tr>
@@ -1,11 +1,12 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Dokument umbenennen{% endblock %}
{% block title %}Dokument bearbeiten{% endblock %}
{% block content %}
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
<div class="pb-4">
<div class="flex flex-col space-y-2 pb-4">
{{ form_row(form.displayName) }}
{{ form_row(form.token) }}
</div>
<button type="submit" class="btn">
aktualisieren
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Config\UploadToken;
use App\Entity\Upload;
use App\Repository\UploadRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The token column is unique, so taking a slot from another document has to free it first - and
* both rows are Uploads, which means nothing guarantees Doctrine writes the release before the
* row that takes it. A single flush would trip the index; these tests pin that it does not.
*/
class UploadRepositoryTokenTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private UploadRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->repository = self::getContainer()->get(UploadRepository::class);
$this->entityManager->createQuery(sprintf('DELETE FROM %s', Upload::class))->execute();
}
public function testADocumentIsFoundByItsToken(): void
{
$document = $this->document('handout.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
$this->entityManager->flush();
$this->assertSame($document, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
}
public function testNothingIsFoundWhileNoDocumentHoldsTheToken(): void
{
$this->document('handout.pdf');
$this->entityManager->flush();
$this->assertNull($this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
}
public function testTakingTheTokenMovesItOffThePreviousDocument(): void
{
$old = $this->document('handout-2025.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
$this->entityManager->flush();
$new = $this->document('handout-2026.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
$releasedFrom = $this->repository->releaseToken(UploadToken::APPLICATION_FORM_HANDOUT, $new);
$this->entityManager->flush();
$this->assertSame($old->getId(), $releasedFrom);
$this->assertNull($old->getToken());
$this->assertSame($new, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
}
public function testReleasingForTheDocumentThatAlreadyHoldsItChangesNothing(): void
{
$document = $this->document('handout.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
$this->entityManager->flush();
$this->assertNull($this->repository->releaseToken(UploadToken::APPLICATION_FORM_HANDOUT, $document));
$this->entityManager->flush();
$this->assertSame($document, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
}
private function document(string $filename): Upload
{
$upload = (new Upload())
->setType(Upload::TYPE_DOCUMENT)
->setFilename($filename)
->setOriginalFilename($filename)
->setMimeType('application/pdf')
->setSize(1024)
;
$this->entityManager->persist($upload);
return $upload;
}
}