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

+82
View File
@@ -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;
}
}
+39 -1
View File
@@ -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(),
]);
}
}
+191 -13
View File
@@ -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>>
*/
+288
View File
@@ -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/');
}
}
+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;
}
}
+367
View File
@@ -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;
}
}