feat: accept form submissions from the websites over an API endpoint

This commit is contained in:
fromme committed 2026-09-24 16:35:06 +02:00
1 parent f48e53fdd0
commit 30ad5e8488
17 files changed
+1350 -4

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
<?php
namespace App\Entity;
use App\Entity\Traits\TimestampableEntity;
use App\Model\ApplicationFormSubmissionDto;
use App\Repository\ApplicationFormSubmissionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
* first, other "collect it and let a human read it" forms later. Unrelated to
* App\Entity\Application, which is a teamer applying for an assignment.
*
* Deliberately neither Blameable nor SoftDeletable: nothing in this application creates these
* rows, and the retention rule for job applications asks for real deletion, so this is the one
* entity here that gets an actual DELETE. A purge has to cover "details" and "payload" as
* well - a json column is exactly what a deletion job forgets.
*
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
* configured without a field as often as with it. "details" is everything else the visitor
* filled in, keyed by the German field label in the order the form asked - display only, never
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
*/
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
// What a list and a dedupe check ask for: the submissions of one form, newest first.
#[ORM\Index(name: 'IDX_F47CE995B5265E72', columns: ['form', 'submitted_at'])]
class ApplicationFormSubmission implements TimestampableEntityInterface
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64)]
private string $form;
#[ORM\Column(length: 255)]
private string $formLabel;
#[ORM\Column]
private \DateTimeImmutable $submittedAt;
#[ORM\Column(nullable: true)]
private ?int $pageUid = null;
#[ORM\Column(length: 2048, nullable: true)]
private ?string $pageUrl = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $firstName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $lastName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $mobile = null;
/**
* A string, not a date: the forms use a text field with a regex rather than a date picker,
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
* what arrived.
*/
#[ORM\Column(length: 64, nullable: true)]
private ?string $birthDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $zipCode = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $city = null;
/**
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
* Display only: the labels are staff-written and change on a reword, so nothing may query
* this, build a column from it or migrate old rows to a new spelling (§3.3).
*
* Note the labels come back in MySQL's order, not the form's - a json column normalises an
* object by sorting its keys by length. Render it in whatever order suits the reviewer; the
* form's own order is still in $payload, which is read as one blob and never sorted by us.
*
* @var array<string, string|bool|list<string>>
*/
#[ORM\Column(type: Types::JSON)]
private array $details = [];
/**
* The request body as received. Mapped keys this application does not know yet are ignored
* on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here,
* so without this copy those answers would be lost for good instead of backfillable.
*
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $payload = [];
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt)
{
$this->uuid = Uuid::v4();
$this->form = $form;
$this->formLabel = $formLabel;
$this->submittedAt = $submittedAt;
}
/**
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
* parseable by then, which is why the constructor can insist on a date.
*
* The raw payload is passed in separately because the DTO no longer has it: the resolver
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
* side has added and this application does not know yet (§3.2). Keeping the body is what
* makes them backfillable instead of lost.
*
* @param array<string, mixed> $payload
*/
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
{
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt());
return $submission
->setPageUid($dto->getPageUid())
->setPageUrl($dto->pageUrl)
->setFirstName($dto->firstName)
->setLastName($dto->lastName)
->setEmail($dto->email)
->setPhone($dto->phone)
->setMobile($dto->mobile)
->setBirthDate($dto->birthDate)
->setStreet($dto->street)
->setZipCode($dto->zipCode)
->setCity($dto->city)
->setDetails($dto->getNormalizedDetails())
->setPayload($payload)
;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getForm(): string
{
return $this->form;
}
public function getFormLabel(): string
{
return $this->formLabel;
}
public function getSubmittedAt(): \DateTimeImmutable
{
return $this->submittedAt;
}
public function getPageUid(): ?int
{
return $this->pageUid;
}
public function setPageUid(?int $pageUid): static
{
$this->pageUid = $pageUid;
return $this;
}
public function getPageUrl(): ?string
{
return $this->pageUrl;
}
public function setPageUrl(?string $pageUrl): static
{
$this->pageUrl = $pageUrl;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
// Same normalization as Contact and Embeddable\Communication: a later match against
// existing people has to compare like with like.
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): static
{
$this->mobile = $mobile;
return $this;
}
public function getBirthDate(): ?string
{
return $this->birthDate;
}
public function setBirthDate(?string $birthDate): static
{
$this->birthDate = $birthDate;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getZipCode(): ?string
{
return $this->zipCode;
}
public function setZipCode(?string $zipCode): static
{
$this->zipCode = $zipCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
/**
* @return array<string, string|bool|list<string>>
*/
public function getDetails(): array
{
return $this->details;
}
/**
* @param array<string, string|bool|list<string>> $details
*/
public function setDetails(array $details): static
{
$this->details = $details;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getPayload(): array
{
return $this->payload;
}
/**
* @param array<string, mixed> $payload
*/
public function setPayload(array $payload): static
{
$this->payload = $payload;
return $this;
}
}