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

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