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

+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* One submitted TYPO3 form, mapped from the request body by #[MapRequestPayload].
*
* Two rules from the contract (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.2, §3.3) shape this
* class and must survive any rewrite:
*
* - **Unknown keys are ignored, never rejected.** The mapped vocabulary grows on the TYPO3
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
* off: it would turn every later mapping change into a 400, silent because the finisher only
* logs it.
* - **Everything but the four basics is optional.** A form is configured without a field as
* often as with it, so an absent key means "not part of this form", never "empty" - hence the
* defaults, which also let the serializer construct the object from a payload of four keys.
*
* The lengths are the storage limits of ApplicationFormSubmission, not an opinion about what a
* visitor may type: they exist so an oversized value is a 400 the TYPO3 side logs, and not a
* database error after the response has been promised.
*/
class ApplicationFormSubmissionDto
{
public const MAX_DETAIL_LABEL_LENGTH = 255;
public const MAX_DETAIL_VALUE_LENGTH = 65535;
/**
* @param array<string, mixed> $details
*/
public function __construct(
#[Assert\NotBlank(message: 'form must not be empty.')]
#[Assert\Length(max: 64, maxMessage: 'form must not exceed {{ limit }} characters.')]
public readonly ?string $form = null,
#[Assert\NotBlank(message: 'formLabel must not be empty.')]
#[Assert\Length(max: 255, maxMessage: 'formLabel must not exceed {{ limit }} characters.')]
public readonly ?string $formLabel = null,
#[Assert\NotBlank(message: 'submittedAt must not be empty.')]
public readonly ?string $submittedAt = null,
// int|string because a page id is unmistakable as "1421": rejecting the quoted form
// would cost a whole submission over a distinction nobody can see in a form editor.
// Read it back through getPageUid().
public readonly int|string|null $pageUid = null,
#[Assert\Length(max: 2048, maxMessage: 'pageUrl must not exceed {{ limit }} characters.')]
public readonly ?string $pageUrl = null,
#[Assert\Length(max: 255)]
public readonly ?string $firstName = null,
#[Assert\Length(max: 255)]
public readonly ?string $lastName = null,
#[Assert\Email(message: 'email is not a valid email address.')]
#[Assert\Length(max: 255)]
public readonly ?string $email = null,
#[Assert\Length(max: 64)]
public readonly ?string $phone = null,
#[Assert\Length(max: 64)]
public readonly ?string $mobile = null,
#[Assert\Length(max: 64)]
public readonly ?string $birthDate = null,
#[Assert\Length(max: 255)]
public readonly ?string $street = null,
#[Assert\Length(max: 32)]
public readonly ?string $zipCode = null,
#[Assert\Length(max: 255)]
public readonly ?string $city = null,
public readonly array $details = [],
) {
}
/**
* The value is generated with \DateTimeInterface::ATOM on the TYPO3 side, but it is still
* outside input: it is validated here rather than trusted, and read back through
* getSubmittedAt() so the endpoint never stores a date it could not parse.
*/
#[Assert\Callback]
public function validateSubmittedAt(ExecutionContextInterface $executionContext): void
{
if (null === $this->submittedAt || '' === $this->submittedAt) {
return;
}
if (null === $this->getSubmittedAt()) {
$executionContext
->buildViolation('submittedAt is not a valid ISO 8601 date and time.')
->atPath('submittedAt')
->addViolation()
;
}
}
#[Assert\Callback]
public function validatePageUid(ExecutionContextInterface $executionContext): void
{
if (null === $this->pageUid || '' === $this->pageUid) {
return;
}
if (null === $this->getPageUid()) {
$executionContext
->buildViolation('pageUid must be a positive integer.')
->atPath('pageUid')
->addViolation()
;
}
}
/**
* details is the only half with no schema: the keys are staff-written German field labels,
* so anything a person can type is a legal key - dots, slashes, umlauts and typos included.
* Only the shape is checked, because that is all the json column and the display can carry:
* a label with content, and a scalar or a flat list as the answer.
*/
#[Assert\Callback]
public function validateDetails(ExecutionContextInterface $executionContext): void
{
foreach ($this->details as $label => $value) {
$label = (string) $label;
if ('' === trim($label) || mb_strlen($label) > self::MAX_DETAIL_LABEL_LENGTH) {
$this->violate(
$executionContext,
sprintf('details contains an empty or overlong label ("%s").', mb_substr($label, 0, 32))
);
continue;
}
if (is_array($value) && false === array_is_list($value)) {
$this->violate($executionContext, sprintf('The value of "%s" must be a flat list.', $label));
continue;
}
foreach (is_array($value) ? $value : [$value] as $single) {
if (null === $single || is_scalar($single)) {
if (is_string($single) && mb_strlen($single) > self::MAX_DETAIL_VALUE_LENGTH) {
$this->violate(
$executionContext,
sprintf('The value of "%s" exceeds %d characters.', $label, self::MAX_DETAIL_VALUE_LENGTH)
);
}
continue;
}
$this->violate(
$executionContext,
sprintf('The value of "%s" must be a string, a boolean or a list of strings.', $label)
);
}
}
}
public function getPageUid(): ?int
{
if (null === $this->pageUid) {
return null;
}
if (is_string($this->pageUid) && 1 !== preg_match('/^\d+$/', $this->pageUid)) {
return null;
}
$pageUid = (int) $this->pageUid;
return $pageUid > 0 ? $pageUid : null;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
if (null === $this->submittedAt || '' === $this->submittedAt) {
return null;
}
// Anchored on a date: \DateTimeImmutable happily reads "now" or "monday", and a
// submission timestamped by a relative expression is not a submission time at all.
if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $this->submittedAt)) {
return null;
}
try {
return new \DateTimeImmutable($this->submittedAt);
} catch (\Exception) {
return null;
}
}
/**
* The blob as it is stored: numbers arrive as strings from the form editor but nothing stops
* a mapping from sending one unquoted, and an empty answer is dropped rather than kept -
* TYPO3 strips those before sending (§3.3), so a label present in the blob always has
* content. The received order is the form's order and is never touched.
*
* @return array<string, string|bool|list<string>>
*/
public function getNormalizedDetails(): array
{
$normalized = [];
foreach ($this->details as $label => $value) {
if (null === $value || '' === $value || [] === $value) {
continue;
}
$normalized[(string) $label] = is_array($value)
? array_values(array_map($this->normalizeListValue(...), $value))
: $this->normalizeValue($value);
}
return $normalized;
}
private function violate(ExecutionContextInterface $executionContext, string $message): void
{
$executionContext
->buildViolation($message)
->atPath('details')
->addViolation()
;
}
private function normalizeValue(mixed $value): string|bool
{
return is_bool($value) ? $value : (string) $value;
}
/**
* A multi-select answer is a list of the labels the visitor picked, never a list of
* true/false: casting keeps the blob readable as what was chosen.
*/
private function normalizeListValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return (string) $value;
}
}