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
@@ -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