feat: provision a teamer from a received application
This commit is contained in:
1 parent
d5f4b12f0a
commit
b530679658
23 files changed
+2433
-148
No files matched your search
@@ -7,6 +7,8 @@ use App\BusProNet\Model\CrmAttributesResponse;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
@@ -83,6 +85,86 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the applicant's address in the CRM, which is what makes them a person BusPro knows.
|
||||
*
|
||||
* BusPro answers with the ids of an address it already held when it recognises the person
|
||||
* ("neuanlage" False), so this never produces a duplicate and needs no pre-flight lookup -
|
||||
* reading an address would require the customer's password, which provisioning deliberately
|
||||
* does not have.
|
||||
*
|
||||
* "ohnemailversand" suppresses BusPro's own mail about the new record: the applicant is asked
|
||||
* to set a password by resetPassword() at the end of provisioning instead, so that a failure
|
||||
* in between never mails somebody who has not been provisioned.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createAddress(ApplicationFormSubmission $submission): NotificationResponse|RegistrationResponse
|
||||
{
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => $submission->toPayload(),
|
||||
'ohnemailversand' => 'True',
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears one CRM selection on a person - the one write to the CRM this application
|
||||
* makes. Everything else about selections is read-only here: roles and hotel codes are synced
|
||||
* from BusPro on every login and an admin may approve a claim, never grant one.
|
||||
*
|
||||
* "login" switches the password check off, which only works when the request identifies the
|
||||
* person by id: both ids are required, and the person id is "idadresseperson" on the way in
|
||||
* although the response calls it "idperson". The element order and the single-selection
|
||||
* payload are what BusPro was verified to accept - the art is undocumented, see
|
||||
* .ddev/plans/buspro-crm-selection-write.md before changing the shape.
|
||||
*
|
||||
* Idempotent: setting a selection that is already set answers änderung=True and changes
|
||||
* nothing, so a caller may write unconditionally and take isUpdated() as the success check.
|
||||
*
|
||||
* The write only goes through where the selection is marked changeable in the BusPro
|
||||
* settings, and that flag is per environment - 1070 is writable in production only.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function updateCrmAttribute(
|
||||
string $email,
|
||||
int $addressId,
|
||||
int $personId,
|
||||
int $selectionId,
|
||||
bool $selected,
|
||||
): NotificationResponse|ProfileUpdateResponse {
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'SelektionCRM_Ändern',
|
||||
'login' => 'J',
|
||||
'email' => $email,
|
||||
'idadresse' => $addressId,
|
||||
'idadresseperson' => $personId,
|
||||
'selektionsmerkmale' => [
|
||||
'selektion' => [
|
||||
// Capitalised as in the read response, which is what the write accepts.
|
||||
['@id' => $selectionId, '@auswahl' => true === $selected ? 'True' : 'False'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* The answer to "Adresse_Neu": the ids of the address the CRM now holds for this person, and
|
||||
* whether it had to create it.
|
||||
*
|
||||
* isNewRecord() is BusPro's own duplicate check ("neuanlage"): False means it matched an address
|
||||
* that already existed and answered with that one's ids, so a caller never has to look for
|
||||
* duplicates itself - which it could not anyway, as reading an address needs the customer's
|
||||
* password. It says nothing about which CRM selections that address carries.
|
||||
*/
|
||||
class RegistrationResponse
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $addressId,
|
||||
private readonly int $personId,
|
||||
private readonly bool $isNewRecord,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAddressId(): int
|
||||
{
|
||||
return $this->addressId;
|
||||
}
|
||||
|
||||
public function getPersonId(): int
|
||||
{
|
||||
return $this->personId;
|
||||
}
|
||||
|
||||
public function isNewRecord(): bool
|
||||
{
|
||||
return $this->isNewRecord;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ResponseParser
|
||||
@@ -50,8 +51,17 @@ class ResponseParser
|
||||
case 'Adressdaten_Ändern':
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'SelektionCRM':
|
||||
case 'SelektionCRM_Ändern':
|
||||
return $this->createCrmAttributesResponse($xml);
|
||||
case 'SelektionCRM_Ändern':
|
||||
// Not a CRM attribute tree: the write answers idadresse + idperson +
|
||||
// änderung, exactly the shape createProfileUpdateResponse() reads. It used
|
||||
// to be routed to createCrmAttributesResponse(), which walks a
|
||||
// "selektionsmerkmale" node that is absent here and would have returned an
|
||||
// empty response with every role flag false - dead code until something
|
||||
// sent the art (.ddev/plans/buspro-crm-selection-write.md).
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'Adresse_Neu':
|
||||
return $this->createRegistrationResponse($xml);
|
||||
}
|
||||
break;
|
||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||
@@ -166,6 +176,34 @@ class ResponseParser
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createRegistrationResponse(\SimpleXMLElement $xml): RegistrationResponse
|
||||
{
|
||||
$addressIdXml = $xml->xpath('idadresse') ?: [];
|
||||
$personIdXml = $xml->xpath('idperson') ?: [];
|
||||
|
||||
// Without both ids there is nothing to carry on with: the CRM selection is set by id, and
|
||||
// a local account without them cannot be matched again on the next login. Better a parse
|
||||
// error naming the response than a half-provisioned applicant.
|
||||
if (0 === count($addressIdXml) || 0 === count($personIdXml)) {
|
||||
throw new ResponseParserException('Missing idadresse or idperson in the Adresse_Neu response');
|
||||
}
|
||||
|
||||
$isNewRecord = false;
|
||||
$isNewRecordXml = $xml->xpath('neuanlage') ?: [];
|
||||
if (0 < count($isNewRecordXml)) {
|
||||
$isNewRecord = 'true' === strtolower((string) $isNewRecordXml[0]);
|
||||
}
|
||||
|
||||
return new RegistrationResponse(
|
||||
(int) $addressIdXml[0],
|
||||
(int) $personIdXml[0],
|
||||
$isNewRecord,
|
||||
);
|
||||
}
|
||||
|
||||
public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\TeamerProvisioningResult;
|
||||
use App\Service\Teamer\TeamerProvisioner;
|
||||
use App\Service\Teamer\TeamerProvisioningException;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Makes a teamer out of an applicant who passed the interview: one click, because the submission
|
||||
* carries everything the CRM needs and there is nothing left for the reviewer to type.
|
||||
*
|
||||
* Behind a feature flag, because provisioning writes to the production CRM and cannot be rehearsed
|
||||
* anywhere else - the teamer selection is only changeable there.
|
||||
*/
|
||||
class CreateTeamerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamerProvisioner $provisioner,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/create-teamer/{uuid}', name: 'app_admin_application_form_submission_create_teamer')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(ApplicationFormSubmission $submission, Request $request): Response
|
||||
{
|
||||
if (false === $this->featureManager->isActive('application_form_teamer_provisioning')) {
|
||||
throw $this->createNotFoundException('Teamer provisioning is not enabled');
|
||||
}
|
||||
|
||||
if (false === $request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('admin/application_form_submission/modal_create_teamer.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->provisioner->provision($submission);
|
||||
$this->addFlash('success', $this->describe($result));
|
||||
$this->log($submission, $result);
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
// Deliberately the exception's own wording: it usually carries BusPro's complaint, and
|
||||
// a reviewer can act on a named mandatory field but not on "something went wrong".
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
$this->logger->error('Unable to provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'error_message' => $e->getMessage(),
|
||||
...$e->getContext(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Names what happened rather than "erledigt": whether BusPro recognised the applicant, whether
|
||||
* an account already existed and - the one thing that needs a human afterwards - whether the
|
||||
* password mail went out.
|
||||
*/
|
||||
private function describe(TeamerProvisioningResult $result): string
|
||||
{
|
||||
if (true === $result->isAlreadyProvisioned()) {
|
||||
return 'Diese Bewerbung wurde bereits übernommen, es wurde nichts erneut angelegt';
|
||||
}
|
||||
|
||||
$message = true === $result->isUserCreated()
|
||||
? sprintf('%s wurde als Teamer:in angelegt', (string) $result->getUser()->getTeamer())
|
||||
: sprintf('%s hat schon ein Konto und ist jetzt Teamer:in', $result->getUser()->getFullName());
|
||||
|
||||
if (false === $result->isAddressCreated()) {
|
||||
$message .= '. In BusPro gab es die Adresse schon, sie wurde übernommen';
|
||||
}
|
||||
|
||||
$message .= true === $result->isPasswordMailSent()
|
||||
? '. BusPro hat die Passwort-Mail versendet'
|
||||
: '. Die Passwort-Mail von BusPro ist nicht rausgegangen - bitte erneut anstoßen';
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function log(ApplicationFormSubmission $submission, TeamerProvisioningResult $result): void
|
||||
{
|
||||
$this->logger->info('Provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'user_id' => $result->getUser()->getId(),
|
||||
'user_email' => $result->getUser()->getEmail(),
|
||||
'bus_pro_address_id' => $submission->getBusProAddressId(),
|
||||
'bus_pro_person_id' => $submission->getBusProPersonId(),
|
||||
'address_created' => $result->isAddressCreated(),
|
||||
'user_created' => $result->isUserCreated(),
|
||||
'already_teamer' => $result->isAlreadyTeamer(),
|
||||
'already_provisioned' => $result->isAlreadyProvisioned(),
|
||||
'password_mail_sent' => $result->isPasswordMailSent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,17 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
|
||||
* first, other "collect it and let a human read it" forms later. Unrelated to
|
||||
* App\Entity\Application, which is a teamer applying for an assignment.
|
||||
* A job application submitted on one of the public websites and posted here by TYPO3. Unrelated
|
||||
* to App\Entity\Application, which is a teamer applying for an assignment.
|
||||
*
|
||||
* The endpoint only ever receives application forms, so every row carries the nine fields it takes
|
||||
* to provision a teamer from it - see ApplicationFormSubmissionDto, where that contract is enforced.
|
||||
* gender is in the constructor because Teamer::$gender is NOT NULL as well; the postal address is
|
||||
* required of a payload but its columns stay nullable, because the address it ends up in
|
||||
* (Embeddable\Address) is nullable throughout - a stricter column here than on the entity it feeds
|
||||
* would be a promise this application does not otherwise keep. What guards provisioning is
|
||||
* TeamerProvisioner's pre-flight check, which refuses a row that predates the contract before
|
||||
* anything is written.
|
||||
*
|
||||
* Deliberately neither Blameable nor SoftDeletable. Nothing here creates a row - TYPO3 does -
|
||||
* and the only thing staff write are the four follow-up dates, where the interesting fact is
|
||||
@@ -22,10 +30,10 @@ use Symfony\Component\Uid\Uuid;
|
||||
* DELETE. A purge has to cover "details" and "payload" as well - a json column is exactly what
|
||||
* a deletion job forgets.
|
||||
*
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
|
||||
* configured without a field as often as with it. "details" is everything else the visitor
|
||||
* filled in, keyed by the German field label in the order the form asked - display only, never
|
||||
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side. "details" is everything
|
||||
* else the visitor filled in, keyed by the German field label in the order the form asked -
|
||||
* display only, never a schema, never queried on (see
|
||||
* .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
|
||||
// What a list and a dedupe check ask for: the submissions of one form, newest first.
|
||||
@@ -51,6 +59,14 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $submittedAt;
|
||||
|
||||
/**
|
||||
* F, M or D - the application's own vocabulary, as on Teamer. Not null, because BusPro
|
||||
* refuses an address without a gender (error 887) and Teamer::$gender is not nullable
|
||||
* either: a submission that could not be provisioned is not worth storing.
|
||||
*/
|
||||
#[ORM\Column(length: 1)]
|
||||
private string $gender;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $pageUid = null;
|
||||
|
||||
@@ -73,13 +89,18 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
private ?string $mobile = null;
|
||||
|
||||
/**
|
||||
* A string, not a date: the forms use a text field with a regex rather than a date picker,
|
||||
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
|
||||
* what arrived.
|
||||
* A string, not a date column: the forms use a text field rather than a date picker, so the
|
||||
* value arrives as text and is stored as it arrived. The contract does pin the format -
|
||||
* YYYY-MM-DD, enforced by the DTO - so getDateOfBirth() can parse it strictly. A date column
|
||||
* would only mean migrating the rows that predate the contract, for no gain.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $birthDate = null;
|
||||
|
||||
/**
|
||||
* Required of every payload, though nullable here: BusPro refuses to create an address without a
|
||||
* street and a postcode (errors 891 and 890), and a town is expected with them.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
@@ -123,6 +144,34 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $secondCallHeldAt = null;
|
||||
|
||||
/**
|
||||
* What provisioning got done, step by step (App\Service\Teamer\TeamerProvisioner). The three
|
||||
* writes are not a transaction and each one can fail with the earlier ones done, so a retry
|
||||
* has to resume rather than restart: with these ids set, a second run never creates a second
|
||||
* BusPro address.
|
||||
*
|
||||
* There is deliberately no stamp for the CRM selection in between. That write is idempotent -
|
||||
* setting a selection that is already set answers "änderung=True" and changes nothing - so a
|
||||
* resume simply sends it again, and a column nobody reads would only pretend to be evidence.
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $busProAddressId = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $busProPersonId = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $teamerCreatedAt = null;
|
||||
|
||||
/**
|
||||
* The account provisioning created, or the one it found for this applicant. Unowned and
|
||||
* without any cascade on purpose: this entity gets a real DELETE (above), and a purged
|
||||
* application must never take a teamer's account with it.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
/**
|
||||
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
|
||||
* Display only: the labels are staff-written and change on a reword, so nothing may query
|
||||
@@ -148,17 +197,19 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $payload = [];
|
||||
|
||||
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt)
|
||||
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt, string $gender)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->form = $form;
|
||||
$this->formLabel = $formLabel;
|
||||
$this->submittedAt = $submittedAt;
|
||||
$this->gender = $gender;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
|
||||
* parseable by then, which is why the constructor can insist on a date.
|
||||
* parseable and gender is one of F/M/D by then, which is why the constructor can insist on
|
||||
* both.
|
||||
*
|
||||
* The raw payload is passed in separately because the DTO no longer has it: the resolver
|
||||
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
|
||||
@@ -169,7 +220,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
*/
|
||||
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
|
||||
{
|
||||
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt());
|
||||
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt(), (string) $dto->gender);
|
||||
|
||||
return $submission
|
||||
->setPageUid($dto->getPageUid())
|
||||
@@ -189,6 +240,66 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant as BusPro's "Adresse_Neu" wants them: the German keys of Teamer::toPayload(),
|
||||
* Embeddable\Address::toPayload() and Embeddable\Communication::toPayload(), which is what
|
||||
* the CRM has always been sent from this application.
|
||||
*
|
||||
* The gender mapping is Teamer::toPayload()'s: F becomes W, everything else goes out as it
|
||||
* came. What BusPro makes of "D" is unverified - it is passed through rather than guessed at.
|
||||
*
|
||||
* The street and the postcode are BusPro's own mandatory fields here, which is why the contract
|
||||
* requires the postal address as a whole. The country is not asked on the forms and is not
|
||||
* guessed either: BusPro fills its own default in, and a wrong country on the record would be
|
||||
* worse than none.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toPayload(): array
|
||||
{
|
||||
$gender = strtoupper($this->gender);
|
||||
|
||||
return [
|
||||
'geburtsdatum' => $this->getDateOfBirth()?->format('d.m.Y'),
|
||||
'geschlecht' => 'F' === $gender ? 'W' : $gender,
|
||||
'vorname' => $this->firstName,
|
||||
'name' => $this->lastName,
|
||||
'anschrift' => [
|
||||
'strasse' => $this->street,
|
||||
'plz' => $this->zipCode,
|
||||
'ort' => $this->city,
|
||||
],
|
||||
'kommunikation' => [
|
||||
'email' => $this->email,
|
||||
'telefonmobil' => $this->mobile,
|
||||
'telefonprivat' => $this->phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The birthday as a date, parsed strictly: the contract guarantees YYYY-MM-DD, and anything
|
||||
* else is null rather than a date \DateTimeImmutable would have invented from it - rows that
|
||||
* predate the contract are exactly that case. Zeroed time of day, so the same row never yields
|
||||
* two different dates.
|
||||
*/
|
||||
public function getDateOfBirth(): ?\DateTimeImmutable
|
||||
{
|
||||
if (null === $this->birthDate || '' === $this->birthDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dateOfBirth = \DateTimeImmutable::createFromFormat('!Y-m-d', $this->birthDate);
|
||||
|
||||
// createFromFormat() happily rolls a 31st of February over into March, which would put a
|
||||
// date of birth on the record the applicant never gave. The round trip is what catches it.
|
||||
if (false === $dateOfBirth || $dateOfBirth->format('Y-m-d') !== $this->birthDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dateOfBirth;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -214,6 +325,11 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function getGender(): string
|
||||
{
|
||||
return $this->gender;
|
||||
}
|
||||
|
||||
public function getPageUid(): ?int
|
||||
{
|
||||
return $this->pageUid;
|
||||
@@ -408,6 +524,68 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBusProAddressId(): ?int
|
||||
{
|
||||
return $this->busProAddressId;
|
||||
}
|
||||
|
||||
public function setBusProAddressId(?int $busProAddressId): static
|
||||
{
|
||||
$this->busProAddressId = $busProAddressId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBusProPersonId(): ?int
|
||||
{
|
||||
return $this->busProPersonId;
|
||||
}
|
||||
|
||||
public function setBusProPersonId(?int $busProPersonId): static
|
||||
{
|
||||
$this->busProPersonId = $busProPersonId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTeamerCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->teamerCreatedAt;
|
||||
}
|
||||
|
||||
public function setTeamerCreatedAt(?\DateTimeImmutable $teamerCreatedAt): static
|
||||
{
|
||||
$this->teamerCreatedAt = $teamerCreatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the BusPro address behind this submission is known - the one thing a retry must
|
||||
* not do twice.
|
||||
*/
|
||||
public function hasBusProAddress(): bool
|
||||
{
|
||||
return null !== $this->busProAddressId && null !== $this->busProPersonId;
|
||||
}
|
||||
|
||||
public function isProvisioned(): bool
|
||||
{
|
||||
return null !== $this->teamerCreatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|bool|list<string>>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
use Symfony\Component\Validator\Exception\ValidationFailedException;
|
||||
|
||||
/**
|
||||
* Answers every failure under the api firewall as JSON.
|
||||
*
|
||||
* Without this, a rejected payload leaves through Symfony's error renderer, which picks its format
|
||||
* from the request: the callers send "Content-Type: application/json" but no "Accept" header, so the
|
||||
* format is html and the body is an error page. The violation messages then survive only in dev,
|
||||
* inside an HTML comment and a <title>; in production the error template carries no message at all.
|
||||
* The TYPO3 finisher logs that body verbatim, which is how a missing field became "some HTML" in the
|
||||
* log of the one system that could act on it.
|
||||
*
|
||||
* So the field names are put where the caller can read them - and into this application's own log as
|
||||
* well, because "submissions stopped arriving" is diagnosed here as often as there.
|
||||
*
|
||||
* Scoped by path, like AccessDeniedListener, and deliberately the same pattern the api firewall uses
|
||||
* (security.yaml, "^/api"): the UI must keep its HTML error pages.
|
||||
*
|
||||
* **The priority is load-bearing.** ExceptionEvent::setResponse() stops propagation, so whoever
|
||||
* answers first is the only one who answers. This has to sit below security's own firewall listener
|
||||
* (priority 1), which turns an AuthenticationException or an AccessDeniedException into the 401 the
|
||||
* caller needs - at a higher priority this listener answered those as a bare 500 - and below
|
||||
* Symfony's ErrorListener::logKernelException (priority 0), so a crash is still logged with its
|
||||
* stack trace. Everything left over is ours, well before the error page is rendered at -128.
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::EXCEPTION, method: 'onKernelException', priority: -8)]
|
||||
class ApiExceptionListener
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
if (false === $this->isApiRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exception = $event->getThrowable();
|
||||
$statusCode = $exception instanceof HttpExceptionInterface
|
||||
? $exception->getStatusCode()
|
||||
: Response::HTTP_INTERNAL_SERVER_ERROR;
|
||||
|
||||
$violations = $this->collectViolations($exception, $request);
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
[
|
||||
'status' => 'error',
|
||||
'message' => $this->describe($exception, $statusCode, $violations),
|
||||
'errors' => $violations,
|
||||
],
|
||||
$statusCode,
|
||||
// Keeps what the status needs to mean anything, WWW-Authenticate on a 401 above all.
|
||||
$exception instanceof HttpExceptionInterface ? $exception->getHeaders() : [],
|
||||
));
|
||||
|
||||
$this->log($request, $exception, $statusCode, $violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* The failed payload fields, as "field", the message the constraint produced, and the shape of
|
||||
* what actually arrived. A violation without a property path keeps the field null rather than
|
||||
* inventing one - a class-level constraint belongs to the payload as a whole.
|
||||
*
|
||||
* @return list<array{field: string|null, message: string, received: string}>
|
||||
*/
|
||||
private function collectViolations(\Throwable $exception, Request $request): array
|
||||
{
|
||||
// RequestPayloadValueResolver wraps the ValidationFailedException in an HttpException whose
|
||||
// status code the endpoint chooses (400 here, not the resolver's default 422).
|
||||
$validationFailed = $exception instanceof ValidationFailedException
|
||||
? $exception
|
||||
: $exception->getPrevious();
|
||||
|
||||
if (false === $validationFailed instanceof ValidationFailedException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
$violations = [];
|
||||
|
||||
foreach ($validationFailed->getViolations() as $violation) {
|
||||
$field = $violation->getPropertyPath();
|
||||
$violations[] = [
|
||||
'field' => '' === $field ? null : $field,
|
||||
'message' => $this->untranslate($violation),
|
||||
'received' => $this->describeReceived($payload, $field),
|
||||
];
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* The violation message in English, rebuilt from its own template.
|
||||
*
|
||||
* ConstraintViolation::getMessage() is translated - the application's locale is German, so a
|
||||
* constraint whose message this application does not phrase itself reaches the caller as
|
||||
* "Dieser Wert sollte vom Typ null|string sein.", in a payload the contract promises is English.
|
||||
* The template and its parameters are untouched by that, and substituting them is exactly what
|
||||
* Symfony does when no translator is configured.
|
||||
*
|
||||
* "{{ value }}" is deliberately not substituted: it would put the rejected value into the
|
||||
* response and into the log, and a rejected payload is still personal data. Which field and what
|
||||
* shape it had is what a caller needs; describeReceived() covers the shape.
|
||||
*/
|
||||
private function untranslate(ConstraintViolationInterface $violation): string
|
||||
{
|
||||
$replacements = ['{{ value }}' => '(omitted)'];
|
||||
|
||||
foreach ($violation->getParameters() as $placeholder => $value) {
|
||||
// Only real placeholders: the parameters may carry extra keys (the serializer's "hint"),
|
||||
// and strtr() would replace those as plain substrings anywhere in the template.
|
||||
if (false === str_starts_with((string) $placeholder, '{{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === is_scalar($value) || null === $value) {
|
||||
$replacements[(string) $placeholder] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return strtr($violation->getMessageTemplate(), $replacements);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the value that was rejected - never the value itself.
|
||||
*
|
||||
* This is the part the framework does not tell anybody: "This value should be of type
|
||||
* null|string." names what was expected and leaves the caller to guess what they sent. A form
|
||||
* engine that hands a date element straight to json_encode sends an object, and seeing its keys
|
||||
* is the difference between a five-minute fix and a guessing game.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function describeReceived(array $payload, string $propertyPath): string
|
||||
{
|
||||
// Top-level key only: a path like "details[Nationalität]" is described by its own bucket,
|
||||
// which is as deep as this endpoint's payload goes.
|
||||
$field = explode('[', explode('.', $propertyPath)[0])[0];
|
||||
|
||||
if ('' === $field || false === array_key_exists($field, $payload)) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return $this->describeShape($payload[$field]);
|
||||
}
|
||||
|
||||
private function describeShape(mixed $value): string
|
||||
{
|
||||
if (true === is_string($value)) {
|
||||
return '' === $value ? 'empty string' : sprintf('string (%d characters)', mb_strlen($value));
|
||||
}
|
||||
|
||||
if (false === is_array($value)) {
|
||||
return get_debug_type($value);
|
||||
}
|
||||
|
||||
if ([] === $value) {
|
||||
return 'empty array';
|
||||
}
|
||||
|
||||
if (true === array_is_list($value)) {
|
||||
return sprintf('list of %d', count($value));
|
||||
}
|
||||
|
||||
// The keys of an object are not personal data, and they are what identifies the sender's
|
||||
// shape - a serialized date arrives as date/timezone_type/timezone, for instance.
|
||||
$keys = array_map(
|
||||
static fn (string|int $key): string => mb_substr((string) $key, 0, 32),
|
||||
array_slice(array_keys($value), 0, 10),
|
||||
);
|
||||
|
||||
return sprintf('object with keys: %s', implode(', ', $keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodePayload(Request $request): array
|
||||
{
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* One line naming what is wrong, for a caller that logs the body and reads no further.
|
||||
*
|
||||
* A server error is described generically: its message is an internal detail, and the log entry
|
||||
* below is where it belongs. Everything else may say what it says - the 4xx messages here are
|
||||
* the endpoint's own contract ("birthDate must be a date in the format YYYY-MM-DD."), written
|
||||
* for exactly this reader.
|
||||
*
|
||||
* @param list<array{field: string|null, message: string}> $violations
|
||||
*/
|
||||
private function describe(\Throwable $exception, int $statusCode, array $violations): string
|
||||
{
|
||||
if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) {
|
||||
return 'Internal server error.';
|
||||
}
|
||||
|
||||
if ([] !== $violations) {
|
||||
return implode(' ', array_column($violations, 'message'));
|
||||
}
|
||||
|
||||
// Not translated either - an HttpException message from the resolver ("Request payload
|
||||
// contains invalid \"json\" data.") is English already, unlike a constraint violation.
|
||||
|
||||
$message = trim($exception->getMessage());
|
||||
|
||||
return '' === $message ? 'Request rejected.' : $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field: string|null, message: string}> $violations
|
||||
*/
|
||||
private function log(Request $request, \Throwable $exception, int $statusCode, array $violations): void
|
||||
{
|
||||
$context = [
|
||||
'method' => $request->getMethod(),
|
||||
'uri' => $request->getRequestUri(),
|
||||
'status' => $statusCode,
|
||||
// The field names and the shape of what arrived, never the values: a rejected payload is
|
||||
// still personal data.
|
||||
'fields' => array_values(array_filter(array_column($violations, 'field'))),
|
||||
'rejected' => $this->summarise($violations),
|
||||
'error_message' => $exception->getMessage(),
|
||||
];
|
||||
|
||||
if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) {
|
||||
// The trace comes from Symfony's own logging at priority 0; this entry is the one that
|
||||
// says which request it was.
|
||||
$this->logger->error('API request failed', $context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('API request rejected', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* One readable line per violation for the log, so this application's own log answers the same
|
||||
* question the response does.
|
||||
*
|
||||
* @param list<array{field: string|null, message: string, received: string}> $violations
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function summarise(array $violations): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $violation): string => sprintf(
|
||||
'%s: %s (received %s)',
|
||||
$violation['field'] ?? 'payload',
|
||||
$violation['message'],
|
||||
$violation['received'],
|
||||
),
|
||||
$violations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The api firewall's own pattern. "/api" alone is matched as well, so a request to the prefix
|
||||
* itself does not slip out as HTML.
|
||||
*/
|
||||
private function isApiRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->getPathInfo();
|
||||
|
||||
return '/api' === $path || str_starts_with($path, '/api/');
|
||||
}
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Model\TeamerProvisioningResult;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Turns an applicant who passed the interview into a teamer.
|
||||
*
|
||||
* Three writes, two of them in BusPro, which is the source of truth for who is a teamer:
|
||||
*
|
||||
* 1. create the applicant's address in the CRM (or learn that it already exists),
|
||||
* 2. set the general teamer selection on it,
|
||||
* 3. create the local User and Teamer - only once 2 succeeded.
|
||||
*
|
||||
* A password mail follows as step 4, so the applicant can log in here.
|
||||
*
|
||||
* **They are not a transaction.** Each step can fail with the earlier ones done, and there is no
|
||||
* undo for an address in BusPro. What makes that survivable is that every outcome is persisted on
|
||||
* the submission as it happens, so a retry resumes instead of restarting: with the ids stored, the
|
||||
* address is never created twice, and the selection write is idempotent by design.
|
||||
*
|
||||
* Nothing here reads the applicant's profile back from BusPro. Adressdaten needs the customer's
|
||||
* password, which provisioning deliberately never has (see
|
||||
* .ddev/plans/application-form-teamer-provisioning.md), so the local records are built from the
|
||||
* data that was sent plus the ids that came back.
|
||||
*
|
||||
* This is also the one place in the application that grants a teamer their CRM marker rather than
|
||||
* reading it. Everything else stays read-only: roles and hotel codes are synced from BusPro on
|
||||
* every login, and an admin may approve a claim, never grant one.
|
||||
*/
|
||||
class TeamerProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly UserDataHandler $userDataHandler,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $teamerSelectionId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
public function provision(ApplicationFormSubmission $submission): TeamerProvisioningResult
|
||||
{
|
||||
// A second click must not create a second anything. The button is gone once a submission
|
||||
// is provisioned, but a resubmitted form or a stale page would still arrive here.
|
||||
if (true === $submission->isProvisioned() && null !== $user = $submission->getUser()) {
|
||||
return new TeamerProvisioningResult(
|
||||
user: $user,
|
||||
addressCreated: false,
|
||||
userCreated: false,
|
||||
alreadyTeamer: true,
|
||||
passwordMailSent: false,
|
||||
alreadyProvisioned: true,
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertProvisionable($submission);
|
||||
|
||||
$addressCreated = $this->createAddress($submission);
|
||||
$this->setTeamerSelection($submission);
|
||||
|
||||
$user = $this->createUser($submission);
|
||||
|
||||
return new TeamerProvisioningResult(
|
||||
user: $user['user'],
|
||||
addressCreated: $addressCreated,
|
||||
userCreated: $user['created'],
|
||||
alreadyTeamer: $user['alreadyTeamer'],
|
||||
// Last, so that a failure in any earlier step never asks somebody to set a password
|
||||
// for an account that does not exist.
|
||||
passwordMailSent: $this->requestPassword($user['user']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields the contract guarantees, checked once before anything is written: a payload that
|
||||
* predates the contract would otherwise fail halfway, and BusPro's own complaint about a
|
||||
* missing field arrives after the address attempt, not before it.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function assertProvisionable(ApplicationFormSubmission $submission): void
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if (true === empty($submission->getFirstName())) {
|
||||
$missing[] = 'Vorname';
|
||||
}
|
||||
if (true === empty($submission->getLastName())) {
|
||||
$missing[] = 'Nachname';
|
||||
}
|
||||
if (true === empty($submission->getEmail())) {
|
||||
$missing[] = 'E-Mail-Adresse';
|
||||
}
|
||||
if (null === $submission->getDateOfBirth()) {
|
||||
$missing[] = 'Geburtsdatum';
|
||||
}
|
||||
// BusPro's own Pflichtfelder for a new address, and the reason the contract requires them:
|
||||
// without a street and a postcode the request fails at step 1 (errors 891 and 890).
|
||||
if (true === empty($submission->getStreet())) {
|
||||
$missing[] = 'Straße';
|
||||
}
|
||||
if (true === empty($submission->getZipCode())) {
|
||||
$missing[] = 'Postleitzahl';
|
||||
}
|
||||
if (true === empty($submission->getCity())) {
|
||||
$missing[] = 'Ort';
|
||||
}
|
||||
|
||||
if ([] === $missing) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new TeamerProvisioningException(sprintf('Die Bewerbung ist unvollständig, es fehlt: %s', implode(', ', $missing)), ['uuid' => $submission->getUuid(), 'missing' => $missing]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1. Returns whether BusPro created the address, or recognised the person and answered
|
||||
* with the ids of the one it already held.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function createAddress(ApplicationFormSubmission $submission): bool
|
||||
{
|
||||
// Resume: the address of this submission exists, and asking for it again would create a
|
||||
// second one.
|
||||
if (true === $submission->hasBusProAddress()) {
|
||||
$this->logger->info('Skip BusPro address creation, the submission already has one', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $submission->getBusProAddressId(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->createAddress($submission);
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
throw new TeamerProvisioningException('Die Adresse konnte in BusPro nicht angelegt werden, BusPro hat nicht geantwortet', ['uuid' => $submission->getUuid()], $e);
|
||||
}
|
||||
|
||||
if (false === $response instanceof RegistrationResponse) {
|
||||
throw new TeamerProvisioningException(sprintf('Die Adresse konnte in BusPro nicht angelegt werden: %s', $this->describe($response)), ['uuid' => $submission->getUuid(), 'bpn_code' => $response->getCode()]);
|
||||
}
|
||||
|
||||
$submission
|
||||
->setBusProAddressId($response->getAddressId())
|
||||
->setBusProPersonId($response->getPersonId())
|
||||
;
|
||||
|
||||
// Immediately, and on its own: from here on a retry must resume rather than create a
|
||||
// second address, whatever the next step does.
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Create BusPro address for application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $response->getAddressId(),
|
||||
'bus_pro_person_id' => $response->getPersonId(),
|
||||
'new_record' => $response->isNewRecord(),
|
||||
]);
|
||||
|
||||
return $response->isNewRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2. Sets the teamer selection unconditionally: the write is idempotent, and whether the
|
||||
* address already carried it is not knowable here - Adresse_Neu answers no selections, and
|
||||
* reading them would need the customer's password.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function setTeamerSelection(ApplicationFormSubmission $submission): void
|
||||
{
|
||||
$email = (string) $submission->getEmail();
|
||||
$addressId = (int) $submission->getBusProAddressId();
|
||||
$personId = (int) $submission->getBusProPersonId();
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->updateCrmAttribute(
|
||||
$email,
|
||||
$addressId,
|
||||
$personId,
|
||||
$this->teamerSelectionId,
|
||||
true,
|
||||
);
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
throw new TeamerProvisioningException('Das Teamer-Merkmal konnte in BusPro nicht gesetzt werden, BusPro hat nicht geantwortet', ['uuid' => $submission->getUuid()], $e);
|
||||
}
|
||||
|
||||
if ($response instanceof NotificationResponse) {
|
||||
throw new TeamerProvisioningException(sprintf('Das Teamer-Merkmal konnte in BusPro nicht gesetzt werden: %s', $this->describe($response)), ['uuid' => $submission->getUuid(), 'bpn_code' => $response->getCode()]);
|
||||
}
|
||||
|
||||
if (false === $response->isUpdated()) {
|
||||
throw new TeamerProvisioningException('BusPro hat das Teamer-Merkmal nicht übernommen. Die Adresse wurde angelegt, der Teamer nicht.', ['uuid' => $submission->getUuid(), 'bus_pro_address_id' => $addressId]);
|
||||
}
|
||||
|
||||
$this->logger->info('Set teamer CRM selection', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $addressId,
|
||||
'bus_pro_person_id' => $personId,
|
||||
'selection_id' => $this->teamerSelectionId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3. The local account, built from what was sent plus the ids that came back.
|
||||
*
|
||||
* @return array{user: User, created: bool, alreadyTeamer: bool}
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function createUser(ApplicationFormSubmission $submission): array
|
||||
{
|
||||
$profileResponse = $this->toProfileResponse($submission);
|
||||
$user = $this->userDataHandler->findLocalUser($profileResponse);
|
||||
|
||||
if (null === $user) {
|
||||
// ROLE_TEAMER needs no approval, which is why it may be granted here at all - see
|
||||
// UserDataHandler for the policy every identity source shares.
|
||||
$user = $this->userDataHandler->createLocalUser($profileResponse, ['ROLE_TEAMER'], isTeamer: true);
|
||||
$this->markProvisioned($submission, $user);
|
||||
|
||||
return ['user' => $user, 'created' => true, 'alreadyTeamer' => false];
|
||||
}
|
||||
|
||||
// A deleted account matched by email: granting it a role would resurrect somebody who
|
||||
// asked to be gone, under their old row. That is a decision for a human, not for a
|
||||
// provisioning click.
|
||||
if (true === $user->isDeleted()) {
|
||||
throw new TeamerProvisioningException('Zu dieser E-Mail-Adresse gehört ein gelöschtes Konto. Der Teamer muss von Hand angelegt werden.', ['uuid' => $submission->getUuid(), 'user_id' => $user->getId()]);
|
||||
}
|
||||
|
||||
$alreadyTeamer = true === $user->hasRole('ROLE_TEAMER') && null !== $user->getTeamer();
|
||||
|
||||
if (false === $alreadyTeamer) {
|
||||
if (null === $user->getTeamer()) {
|
||||
$user->setTeamer(Teamer::fromApiResponse($profileResponse));
|
||||
}
|
||||
|
||||
$this->userDataHandler->grantTeamerRole($user);
|
||||
}
|
||||
|
||||
$this->markProvisioned($submission, $user);
|
||||
|
||||
$this->logger->info('Provision existing local user as teamer', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'already_teamer' => $alreadyTeamer,
|
||||
]);
|
||||
|
||||
return ['user' => $user, 'created' => false, 'alreadyTeamer' => $alreadyTeamer];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4. BusPro mails the applicant a password, which is what lets them log in here at all -
|
||||
* this application never sets one for a CRM account.
|
||||
*
|
||||
* A failure is reported, not thrown: by now the teamer exists, and the mail can be sent again
|
||||
* from the user's own page. Undoing the three writes over an unsent mail would be worse.
|
||||
*/
|
||||
private function requestPassword(User $user): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->apiClient->resetPassword((string) $user->getEmail());
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
$this->logger->error('Unable to request the BusPro password mail for a new teamer', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === $response->isSuccessful()) {
|
||||
$this->logger->error('BusPro refused the password mail for a new teamer', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'bpn_code' => $response->getCode(),
|
||||
'bpn_message' => $response->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function markProvisioned(ApplicationFormSubmission $submission, User $user): void
|
||||
{
|
||||
$submission
|
||||
->setUser($user)
|
||||
->setTeamerCreatedAt(new \DateTimeImmutable())
|
||||
;
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* The submission as a profile response, which is the shape UserDataHandler and
|
||||
* Teamer::fromApiResponse() read. Not read back from BusPro on purpose: Adressdaten would
|
||||
* need the customer's password.
|
||||
*
|
||||
* The gender goes in as it is stored - F/M/D is this application's own vocabulary, and the
|
||||
* mapping to BusPro's W happens on the way out, not here.
|
||||
*/
|
||||
private function toProfileResponse(ApplicationFormSubmission $submission): ProfileResponse
|
||||
{
|
||||
$address = new Address();
|
||||
$address
|
||||
->setStreet($submission->getStreet())
|
||||
->setPostCode($submission->getZipCode())
|
||||
->setCity($submission->getCity())
|
||||
;
|
||||
|
||||
$communication = new Communication();
|
||||
$communication
|
||||
->setEmail($submission->getEmail())
|
||||
->setPhone($submission->getPhone())
|
||||
->setMobile($submission->getMobile())
|
||||
;
|
||||
|
||||
$profileResponse = new ProfileResponse();
|
||||
|
||||
return $profileResponse
|
||||
->setAddressId($submission->getBusProAddressId())
|
||||
->setPersonId($submission->getBusProPersonId())
|
||||
->setFirstName($submission->getFirstName())
|
||||
->setName($submission->getLastName())
|
||||
->setGender($submission->getGender())
|
||||
->setDateOfBirth($submission->getDateOfBirth())
|
||||
->setAddress($address)
|
||||
->setCommunication($communication)
|
||||
;
|
||||
}
|
||||
|
||||
private function describe(NotificationResponse $response): string
|
||||
{
|
||||
$message = trim((string) $response->getMessage());
|
||||
|
||||
if ('' === $message) {
|
||||
return sprintf('BusPro hat mit dem Code %s geantwortet', (string) $response->getCode());
|
||||
}
|
||||
|
||||
return sprintf('%s (%s)', $message, (string) $response->getCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
/**
|
||||
* Provisioning stopped, with whatever the earlier steps had already done left in place.
|
||||
*
|
||||
* The message is shown to the reviewer as it is, which is why it is one of the few strings in this
|
||||
* application written for a human rather than a log: they are the only audience, and the useful part
|
||||
* is usually BusPro's own complaint about a mandatory field, which no rewording here would improve.
|
||||
* The technical detail goes to the log through getContext().
|
||||
*/
|
||||
class TeamerProvisioningException extends \Exception
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly array $context = [],
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user