feat: provision a teamer from a received application

This commit is contained in:
fromme committed 2026-09-25 14:36:24 +02:00
1 parent d5f4b12f0a
commit b530679658
23 files changed
+2433 -148

No files matched your search

+44 -5
View File
@@ -15,9 +15,22 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* 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.
* - **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
* (.ddev/plans/application-form-teamer-provisioning.md).
*
* 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
@@ -25,6 +38,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
*/
class ApplicationFormSubmissionDto
{
/**
* @var list<string>
*/
public const GENDERS = ['F', 'M', 'D'];
public const MAX_DETAIL_LABEL_LENGTH = 255;
public const MAX_DETAIL_VALUE_LENGTH = 65535;
@@ -47,10 +65,13 @@ class ApplicationFormSubmissionDto
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,
@@ -58,19 +79,37 @@ class ApplicationFormSubmissionDto
public readonly ?string $phone = null,
#[Assert\Length(max: 64)]
public readonly ?string $mobile = null,
#[Assert\Length(max: 64)]
// 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.
#[Assert\NotBlank(message: 'gender must not be empty.')]
#[Assert\Choice(choices: self::GENDERS, message: 'gender must be one of F, M or D.')]
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().
// 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 = [],
) {
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Model;
use App\Entity\User;
/**
* What provisioning actually did, so the controller can phrase the flash and the log entry
* without re-deriving any of it (App\Service\Teamer\TeamerProvisioner).
*
* Every flag is a fact the reviewer wants to know: whether BusPro created the address or
* recognised the applicant, whether an account already existed, and whether the password mail
* went out - the last is not fatal, but without it the teamer cannot log in until somebody sends
* it again.
*/
class TeamerProvisioningResult
{
public function __construct(
private readonly User $user,
private readonly bool $addressCreated,
private readonly bool $userCreated,
private readonly bool $alreadyTeamer,
private readonly bool $passwordMailSent,
private readonly bool $alreadyProvisioned = false,
) {
}
public function getUser(): User
{
return $this->user;
}
/**
* False means BusPro matched an address it already held and answered with its ids - not a
* failure, and not a duplicate either.
*/
public function isAddressCreated(): bool
{
return $this->addressCreated;
}
public function isUserCreated(): bool
{
return $this->userCreated;
}
public function isAlreadyTeamer(): bool
{
return $this->alreadyTeamer;
}
public function isPasswordMailSent(): bool
{
return $this->passwordMailSent;
}
/**
* The submission had been provisioned before and nothing was sent to BusPro at all.
*/
public function isAlreadyProvisioned(): bool
{
return $this->alreadyProvisioned;
}
}