diff --git a/config/services.yaml b/config/services.yaml
index ebf70e2..dab19d3 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -4,6 +4,36 @@ parameters:
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
+ # BusPro CRM selection id => hotel code. A person holding a selected attribute from this
+ # list is a house manager for that house. Matched by id and not by the "Hausleitung XXX"
+ # label, so renaming a selection in BusPro cannot silently grant or revoke a house.
+ # The commented entries are BusPro selections whose code is not a house in the "houses"
+ # parameter below: granting them would let someone in who then sees no assignments and
+ # no dispositions at all. Uncomment one once its house exists. The reverse gap is fine -
+ # not every house has a selection.
+ bpn_crm_house_manager_ids:
+ 1299: 'SSL'
+ 1300: 'SST'
+ 1301: 'MVK'
+ # 1302: 'ASB'
+ 1303: 'LPJ'
+ 1304: 'DKS'
+ 1305: 'DGS'
+ 1306: 'DPW'
+ 1307: 'DKI'
+ 1308: 'DWW'
+ 1309: 'PMV'
+ # 1352: 'ASG'
+ 1371: 'ASC'
+ 1373: 'PCJ'
+ 1374: 'SBW'
+ # 1375: 'UCH'
+ # 1376: 'SZO'
+ 1377: 'KHH'
+ 1459: 'SVS'
+ 1461: 'SHM'
+ 1462: 'AGR'
+
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
@@ -118,6 +148,7 @@ services:
bpn_crm_id_admin: '%bpn_crm_id_admin%'
bpn_crm_id_manager: '%bpn_crm_id_manager%'
bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
+ bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
bpn_default_hotel_code: '%bpn_default_hotel_code%'
App\Twig\AppRuntime:
diff --git a/docs/user-roles.md b/docs/user-roles.md
index 9c93b73..eb1c1da 100644
--- a/docs/user-roles.md
+++ b/docs/user-roles.md
@@ -65,7 +65,11 @@ slice it, and picking the right one matters:
| admin | attribute id `%bpn_crm_id_admin%`, selected | `isAdmin` |
| Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` |
| teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` |
-| Hausleitung | label matching `/^Hausleitung ([A-Z0-9]+)$/`, selected | `isHouseManager` + one hotel code per match |
+| Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to |
+
+`bpn_crm_house_manager_ids` (`config/services.yaml`) maps a BusPro selection id to a hotel
+code. Entries whose code is not a house in the `houses` parameter are kept commented out:
+such a house manager could log in but would see no assignments and no dispositions at all.
`UserDataHandler::collectPendingRoles()` turns those into markers — `isManager` wins over
`isHouseManager`, they are never both claimed, though the hotel codes of a Hausleitung are
diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php
index 9a3c9b2..c9081cd 100644
--- a/src/BusProNet/ResponseParser.php
+++ b/src/BusProNet/ResponseParser.php
@@ -185,9 +185,12 @@ class ResponseParser
;
$attributes[] = $attribute;
- if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attributeLabel, $matches) && true === $attribute->isSelected()) {
+ // Matched by id, like every other role: the "Hausleitung XXX" label is BusPro
+ // wording and must not decide who gets access to which house.
+ $houseManagerCode = $this->config['bpn_crm_house_manager_ids'][$attribute->getId()] ?? null;
+ if (null !== $houseManagerCode && true === $attribute->isSelected()) {
$isHouseManager = true;
- $hotelCodes[] = $matches[1];
+ $hotelCodes[] = $houseManagerCode;
}
if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
$isAdmin = true;
@@ -294,13 +297,14 @@ class ResponseParser
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
- $optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer']);
+ $optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setDefaults([
'bpn_default_hotel_code' => null,
]);
$optionsResolver->setAllowedTypes('bpn_crm_id_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int');
+ $optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array');
$optionsResolver->setAllowedTypes('bpn_default_hotel_code', ['string', 'null']);
return $optionsResolver->resolve($options);
diff --git a/tests/BusProNet/ResponseParserTest.php b/tests/BusProNet/ResponseParserTest.php
index 4a8d126..cd92a3f 100644
--- a/tests/BusProNet/ResponseParserTest.php
+++ b/tests/BusProNet/ResponseParserTest.php
@@ -86,6 +86,25 @@ class ResponseParserTest extends TestCase
$this->assertCount(3, $response->getAttributeGroups());
}
+ /**
+ * The house of a "Hausleitung" selection comes from the configured id map, never from the
+ * label: an id that is not mapped grants nothing. That is also what keeps the houses
+ * commented out in bpn_crm_house_manager_ids from letting someone in who would then see
+ * no assignments and no dispositions at all.
+ */
+ public function testParseCrmAttributesIgnoresAnUnmappedHouseManagerSelection(): void
+ {
+ $content = 'SelektionCRM141747224526';
+
+ $parser = $this->getParserInstance();
+ $response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
+
+ $this->assertInstanceOf(CrmAttributesResponse::class, $response);
+ $this->assertTrue($response->isTeamer());
+ $this->assertFalse($response->isHouseManager());
+ $this->assertSame([], $response->getHotelCodes());
+ }
+
public function testParseCrmAttributesOfAnEmptyResponse(): void
{
$content = 'SelektionCRM141747224526';
@@ -170,6 +189,14 @@ class ResponseParserTest extends TestCase
'bpn_crm_id_admin' => 1292,
'bpn_crm_id_manager' => 1293,
'bpn_crm_id_teamer' => 1070,
+ // Deliberately a small excerpt of the configured map: the fixtures only carry
+ // these three houses, and 1301 is here to be a mapped id no fixture selects.
+ 'bpn_crm_house_manager_ids' => [
+ 1299 => 'SSL',
+ 1301 => 'MVK',
+ 1304 => 'DKS',
+ 1305 => 'DGS',
+ ],
]);
}
}