*/ 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 */ public const ACCEPTED_GENDERS = ['F', 'W', 'M', 'D']; public const MAX_DETAIL_LABEL_LENGTH = 255; public const MAX_DETAIL_VALUE_LENGTH = 65535; /** * @param array $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> */ 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; } }