385 lines
16 KiB
PHP
385 lines
16 KiB
PHP
<?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 with the TYPO3 side 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.
|
|
* - **Nine fields are required of every payload**: lastName, firstName, birthDate, email, gender,
|
|
* eu, street, zipCode and city. They are what it takes to provision a teamer from a submission,
|
|
* and BusPro's own mandatory fields for creating an address are among them (errors 885-891: Name,
|
|
* Vorname, Geschlecht, EMail, PLZ, Straße) - a payload without those cannot become a teamer at
|
|
* all, and asking a reviewer to retype them from the answers would be worse than refusing the
|
|
* payload. The endpoint only ever receives application forms, so this holds for every payload and
|
|
* there is no form-type discriminator.
|
|
*
|
|
* Everything beyond the nine - phone, mobile, nationality, bank details - stays optional and is
|
|
* filled in by the teamer themselves afterwards, so an absent key there still means "not part of
|
|
* this form", never "empty".
|
|
*
|
|
* A 400 costs a whole application, and the visitor sees their form submit normally either way.
|
|
* That is accepted deliberately: the same people control the TYPO3 forms and this endpoint, the
|
|
* response body names the offending field and the finisher logs it.
|
|
*
|
|
* 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
|
|
{
|
|
/**
|
|
* The stored vocabulary. W is not in here: it is accepted on the wire and folded into F by
|
|
* getGender(), so nothing downstream ever sees two spellings of the same gender.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const GENDERS = ['F', 'M', 'D'];
|
|
|
|
/**
|
|
* What a payload may say, before case is folded. The TYPO3 forms are German and some of them
|
|
* post the German W for female; refusing it - or refusing a lowercase w - would cost a whole
|
|
* application over a spelling. Checked by validateGender() rather than Assert\Choice, because
|
|
* Choice compares case-sensitively. Read the gender back through getGender(), never off the
|
|
* property.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const ACCEPTED_GENDERS = ['F', 'W', 'M', 'D'];
|
|
|
|
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\NotBlank(message: 'firstName must not be empty.')]
|
|
#[Assert\Length(max: 255)]
|
|
public readonly ?string $firstName = null,
|
|
#[Assert\NotBlank(message: 'lastName must not be empty.')]
|
|
#[Assert\Length(max: 255)]
|
|
public readonly ?string $lastName = null,
|
|
#[Assert\NotBlank(message: 'email must not be empty.')]
|
|
#[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,
|
|
// The one field whose format is nailed down rather than stored as typed: provisioning
|
|
// parses it as a date, and a free-text birthday would only fail later, in front of a
|
|
// reviewer who cannot fix it.
|
|
#[Assert\NotBlank(message: 'birthDate must not be empty.')]
|
|
#[Assert\Date(message: 'birthDate must be a date in the format YYYY-MM-DD.')]
|
|
public readonly ?string $birthDate = null,
|
|
// The application's own vocabulary, not BusPro's: ApplicationFormSubmission::toPayload()
|
|
// maps F to W on the way out, exactly as Teamer::toPayload() does. W is accepted on the
|
|
// way in as the German spelling of F, in either case, and normalised by getGender() - the
|
|
// storage stays F/M/D, and the W that reaches BusPro is the one toPayload() puts there.
|
|
// Validated by validateGender(), not Assert\Choice.
|
|
#[Assert\NotBlank(message: 'gender must not be empty.')]
|
|
public readonly ?string $gender = null,
|
|
// The whole postal address, required together. Street and postcode are BusPro's own
|
|
// Pflichtfelder for a new address (errors 891 and 890), so a submission without them is one
|
|
// no teamer can be provisioned from; the town is not on that list, but an address carrying a
|
|
// street and a postcode without one is not an address worth writing to the CRM either.
|
|
#[Assert\NotBlank(message: 'street must not be empty.')]
|
|
#[Assert\Length(max: 255)]
|
|
public readonly ?string $street = null,
|
|
#[Assert\NotBlank(message: 'zipCode must not be empty.')]
|
|
#[Assert\Length(max: 32)]
|
|
public readonly ?string $zipCode = null,
|
|
#[Assert\NotBlank(message: 'city must not be empty.')]
|
|
#[Assert\Length(max: 255)]
|
|
public readonly ?string $city = null,
|
|
// Whether the applicant holds an EU (incl. Switzerland) nationality - the one payload
|
|
// field a rule depends on, hence mapped rather than left in $details. bool|string|int
|
|
// for the same reason as pageUid: a form posts "1" or "" as readily as a JSON boolean,
|
|
// and losing a whole application over the spelling would be absurd. Read it back
|
|
// through isEu(). Required, because the receipt mail depends on it - but required as a
|
|
// value, not as a truth: NotNull rather than NotBlank, or a lawful "no" would be a 400.
|
|
#[Assert\NotNull(message: 'eu must not be omitted.')]
|
|
public readonly bool|string|int|null $eu = 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.
|
|
*/
|
|
/**
|
|
* A value that is neither a boolean nor one of the spellings a form posts is a 400 rather
|
|
* than a silent null: null means "this form does not ask", and a misconfigured mapping that
|
|
* quietly stopped every receipt mail would be the worst of both worlds. The same reasoning
|
|
* as validatePageUid().
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validateEu(ExecutionContextInterface $executionContext): void
|
|
{
|
|
if (null === $this->eu) {
|
|
return;
|
|
}
|
|
|
|
if (null === $this->isEu()) {
|
|
$executionContext
|
|
->buildViolation('eu must be a boolean.')
|
|
->atPath('eu')
|
|
->addViolation()
|
|
;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same reasoning as validateEu(): getGender() folds case and the German W away, and what
|
|
* survives that must be one of the three the application stores. Anything else is a 400 with
|
|
* the vocabulary named, rather than a row nothing downstream can read.
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validateGender(ExecutionContextInterface $executionContext): void
|
|
{
|
|
if (null === $this->gender || '' === $this->gender) {
|
|
return;
|
|
}
|
|
|
|
if (!in_array($this->getGender(), self::GENDERS, true)) {
|
|
$executionContext
|
|
->buildViolation('gender must be one of F, W, M or D.')
|
|
->atPath('gender')
|
|
->addViolation()
|
|
;
|
|
}
|
|
}
|
|
|
|
#[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)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* null for "the form does not ask" as well as for a value that made no sense - the caller
|
|
* treats both as "no automatic mail", and validateEu() has already turned the second into a
|
|
* 400 by the time anything reads this.
|
|
*/
|
|
public function isEu(): ?bool
|
|
{
|
|
if (is_bool($this->eu)) {
|
|
return $this->eu;
|
|
}
|
|
|
|
return match (is_string($this->eu) ? mb_strtolower(trim($this->eu)) : $this->eu) {
|
|
null => null,
|
|
1, '1', 'true' => true,
|
|
0, '0', 'false', '' => false,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The gender as it is stored: upper-cased, with the German W for female folded into F. A form
|
|
* posts "w" as readily as "W" and neither spelling is worth an application, so case is folded
|
|
* here rather than demanded of the sender - the same reasoning as isEu() and getPageUid().
|
|
*
|
|
* Called by validateGender() as well, so this runs on unvalidated input: it returns whatever
|
|
* the payload said, upper-cased, and it is validateGender() that decides whether that is one
|
|
* of GENDERS. Everything after validation sees only F, M, D or null.
|
|
*/
|
|
public function getGender(): ?string
|
|
{
|
|
if (null === $this->gender) {
|
|
return null;
|
|
}
|
|
|
|
$gender = strtoupper(trim($this->gender));
|
|
|
|
return 'W' === $gender ? 'F' : $gender;
|
|
}
|
|
|
|
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, 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;
|
|
}
|
|
}
|