Files
myep-team/src/Model/ApplicationFormSubmissionDto.php
T

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;
}
}